diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml new file mode 100644 index 0000000000..58bdab970d --- /dev/null +++ b/.github/workflows/unit_tests.yaml @@ -0,0 +1,35 @@ +name: Unit Tests (vitest) +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + paths: + - '.github/workflows/unit_tests.yaml' + - 'lib/**' + - 'test/unit/**' + - 'vitest.config.ts' + - 'tsconfig*.json' + - 'package.json' + - 'package-lock.json' + +jobs: + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm run test:unit diff --git a/docker-compose-macos.yaml b/docker-compose-macos.yaml index 2d0840e20a..0cc771ba1c 100644 --- a/docker-compose-macos.yaml +++ b/docker-compose-macos.yaml @@ -108,20 +108,7 @@ services: container_name: devicehub-processor env_file: - scripts/variables.env - command: stf processor --name processor --connect-app-dealer tcp://devicehub-triproxy-app:7160 --connect-dev-dealer tcp://devicehub-triproxy-dev:7260 - depends_on: - devicehub-migrate: - condition: service_completed_successfully - restart: unless-stopped - networks: - devicehub: - devicehub-reaper: - build: . - image: vkcom/devicehub - container_name: devicehub-reaper - env_file: - - scripts/variables.env - command: stf reaper --name reaper001 --connect-push tcp://devicehub-triproxy-dev:7270 --connect-sub tcp://devicehub-triproxy-dev:7250 --heartbeat-timeout 30000 + command: stf processor --name processor --connect-proxy tcp://devicehub-proxy:7110 --bind-router "tcp://*:7160" --heartbeat-timeout 30000 depends_on: devicehub-migrate: condition: service_completed_successfully @@ -160,41 +147,22 @@ services: container_name: devicehub-storage-temp env_file: - scripts/variables.env - command: stf storage-temp --port 3000 --save-dir /tmp --connect-sub tcp://devicehub-triproxy-app:7150 --connect-push tcp://devicehub-triproxy-app:7170 --secret=${STF_SECRET} - depends_on: - devicehub-migrate: - condition: service_completed_successfully - restart: unless-stopped - networks: - devicehub: - devicehub-triproxy-app: - build: . - image: vkcom/devicehub - container_name: devicehub-triproxy-app - env_file: - - scripts/variables.env - command: stf triproxy app001 --bind-pub "tcp://*:7150" --bind-dealer "tcp://*:7160" --bind-pull "tcp://*:7170" - ports: - - "7150:7150" - - "7160:7160" - - "7170:7170" + command: stf storage-temp --port 3000 --save-dir /tmp --secret=${STF_SECRET} depends_on: devicehub-migrate: condition: service_completed_successfully restart: unless-stopped networks: devicehub: - devicehub-triproxy-dev: + devicehub-proxy: build: . image: vkcom/devicehub - container_name: devicehub-triproxy-dev + container_name: devicehub-proxy env_file: - scripts/variables.env - command: stf triproxy --name dev001 --bind-pub "tcp://*:7250" --bind-dealer "tcp://*:7260" --bind-pull "tcp://*:7270" + command: stf proxy proxy001 --bind-router "tcp://*:7110" ports: - - "7250:7250" - - "7260:7260" - - "7270:7270" + - "7110:7110" depends_on: devicehub-migrate: condition: service_completed_successfully @@ -207,7 +175,7 @@ services: container_name: devicehub-websocket env_file: - scripts/variables.env - command: stf websocket --port 3000 --storage-url http://devicehub-storage-temp:3000/ --connect-sub tcp://devicehub-triproxy-app:7150 --connect-push tcp://devicehub-triproxy-app:7170 --connect-sub-dev tcp://devicehub-triproxy-dev:7250 --connect-push-dev tcp://devicehub-triproxy-dev:7270 --secret=${STF_SECRET} + command: stf websocket --port 3000 --storage-url http://devicehub-storage-temp:3000/ --connect-proxy tcp://devicehub-proxy:7110 --secret=${STF_SECRET} depends_on: devicehub-migrate: condition: service_completed_successfully @@ -220,7 +188,7 @@ services: container_name: devicehub-api env_file: - scripts/variables.env - command: stf api --port 3000 --connect-sub tcp://devicehub-triproxy-app:7150 --connect-push tcp://devicehub-triproxy-app:7170 --connect-sub-dev tcp://devicehub-triproxy-dev:7250 --connect-push-dev tcp://devicehub-triproxy-dev:7270 --secret=${STF_SECRET} + command: stf api --port 3000 --connect-proxy tcp://devicehub-proxy:7110 --secret=${STF_SECRET} depends_on: devicehub-migrate: condition: service_completed_successfully @@ -233,7 +201,7 @@ services: container_name: devicehub-api-groups-engine env_file: - scripts/variables.env - command: stf groups-engine --connect-push tcp://devicehub-triproxy-app:7170 --connect-push-dev tcp://devicehub-triproxy-dev:7270 + command: stf groups-engine --connect-proxy tcp://devicehub-proxy:7110 depends_on: devicehub-migrate: condition: service_completed_successfully @@ -252,7 +220,7 @@ services: devicehub-migrate: condition: service_completed_successfully restart: unless-stopped - command: stf provider --name devicehub-provider --adb-host host.docker.internal --no-cleanup --connect-sub tcp://devicehub-triproxy-dev:7250 --connect-push tcp://devicehub-triproxy-dev:7270 --storage-url http://devicehub-storage-plugin-apk:3000/ --public-ip ${STF_DOMAIN} --min-port=12010 --max-port=12100 --heartbeat-interval 10000 --screen-ws-url-pattern "wss://${STF_DOMAIN}:${STF_PORT}/d/devicehub-provider/<%= publicPort %>/" --secret=${STF_SECRET} + command: stf provider --name devicehub-provider --adb-host host.docker.internal --no-cleanup --connect-processor tcp://devicehub-processor:7160 --storage-url http://devicehub-storage-plugin-apk:3000/ --public-ip ${STF_DOMAIN} --min-port=12010 --max-port=12100 --heartbeat-interval 10000 --screen-ws-url-pattern "wss://${STF_DOMAIN}:${STF_PORT}/d/devicehub-provider/<%= publicPort %>/" --secret=${STF_SECRET} networks: devicehub: devicehub-ssl: diff --git a/docker-compose-prod.yaml b/docker-compose-prod.yaml index 123ce8ded9..219abe6af5 100644 --- a/docker-compose-prod.yaml +++ b/docker-compose-prod.yaml @@ -124,20 +124,7 @@ services: container_name: devicehub-processor env_file: - scripts/variables.env - command: stf processor --name processor --connect-app-dealer tcp://devicehub-triproxy-app:7160 --connect-dev-dealer tcp://devicehub-triproxy-dev:7260 - depends_on: - devicehub-migrate: - condition: service_completed_successfully - restart: unless-stopped - networks: - devicehub: - devicehub-reaper: - build: . - image: vkcom/devicehub - container_name: devicehub-reaper - env_file: - - scripts/variables.env - command: stf reaper --name reaper001 --connect-push tcp://devicehub-triproxy-dev:7270 --connect-sub tcp://devicehub-triproxy-dev:7250 --heartbeat-timeout 30000 + command: stf processor --name processor --connect-proxy tcp://devicehub-proxy:7110 --bind-router "tcp://*:7160" --heartbeat-timeout 30000 depends_on: devicehub-migrate: condition: service_completed_successfully @@ -176,41 +163,22 @@ services: container_name: devicehub-storage-temp env_file: - scripts/variables.env - command: stf storage-temp --port 3000 --save-dir /tmp --connect-sub tcp://devicehub-triproxy-app:7150 --connect-push tcp://devicehub-triproxy-app:7170 --secret=${STF_SECRET} - depends_on: - devicehub-migrate: - condition: service_completed_successfully - restart: unless-stopped - networks: - devicehub: - devicehub-triproxy-app: - build: . - image: vkcom/devicehub - container_name: devicehub-triproxy-app - env_file: - - scripts/variables.env - command: stf triproxy app001 --bind-pub "tcp://*:7150" --bind-dealer "tcp://*:7160" --bind-pull "tcp://*:7170" - ports: - - "7150:7150" - - "7160:7160" - - "7170:7170" + command: stf storage-temp --port 3000 --save-dir /tmp --secret=${STF_SECRET} depends_on: devicehub-migrate: condition: service_completed_successfully restart: unless-stopped networks: devicehub: - devicehub-triproxy-dev: + devicehub-proxy: build: . image: vkcom/devicehub - container_name: devicehub-triproxy-dev + container_name: devicehub-proxy env_file: - scripts/variables.env - command: stf triproxy --name dev001 --bind-pub "tcp://*:7250" --bind-dealer "tcp://*:7260" --bind-pull "tcp://*:7270" + command: stf proxy proxy001 --bind-router "tcp://*:7110" ports: - - "7250:7250" - - "7260:7260" - - "7270:7270" + - "7110:7110" depends_on: devicehub-migrate: condition: service_completed_successfully @@ -223,7 +191,7 @@ services: container_name: devicehub-websocket env_file: - scripts/variables.env - command: stf websocket --port 3000 --storage-url http://devicehub-storage-temp:3000/ --connect-sub tcp://devicehub-triproxy-app:7150 --connect-push tcp://devicehub-triproxy-app:7170 --connect-sub-dev tcp://devicehub-triproxy-dev:7250 --connect-push-dev tcp://devicehub-triproxy-dev:7270 --secret=${STF_SECRET} + command: stf websocket --port 3000 --storage-url http://devicehub-storage-temp:3000/ --connect-proxy tcp://devicehub-proxy:7110 --secret=${STF_SECRET} depends_on: devicehub-migrate: condition: service_completed_successfully @@ -236,7 +204,7 @@ services: container_name: devicehub-api env_file: - scripts/variables.env - command: stf api --port 3000 --connect-sub tcp://devicehub-triproxy-app:7150 --connect-push tcp://devicehub-triproxy-app:7170 --connect-sub-dev tcp://devicehub-triproxy-dev:7250 --connect-push-dev tcp://devicehub-triproxy-dev:7270 --secret=${STF_SECRET} + command: stf api --port 3000 --connect-proxy tcp://devicehub-proxy:7110 --secret=${STF_SECRET} depends_on: devicehub-migrate: condition: service_completed_successfully @@ -249,7 +217,7 @@ services: container_name: devicehub-api-groups-engine env_file: - scripts/variables.env - command: stf groups-engine --connect-push tcp://devicehub-triproxy-app:7170 --connect-push-dev tcp://devicehub-triproxy-dev:7270 + command: stf groups-engine --connect-proxy tcp://devicehub-proxy:7110 depends_on: devicehub-migrate: condition: service_completed_successfully @@ -270,7 +238,7 @@ services: adbd: condition: service_healthy restart: unless-stopped - command: stf provider --name devicehub-provider --adb-host adbd --no-cleanup --connect-sub tcp://devicehub-triproxy-dev:7250 --connect-push tcp://devicehub-triproxy-dev:7270 --storage-url http://devicehub-storage-plugin-apk:3000/ --public-ip ${STF_DOMAIN} --min-port=12010 --max-port=12100 --heartbeat-interval 10000 --screen-ws-url-pattern "wss://${STF_DOMAIN}:${STF_PORT}/d/devicehub-provider/<%= publicPort %>/" --secret=${STF_SECRET} + command: stf provider --name devicehub-provider --adb-host adbd --no-cleanup --connect-processor tcp://devicehub-processor:7160 --storage-url http://devicehub-storage-plugin-apk:3000/ --public-ip ${STF_DOMAIN} --min-port=12010 --max-port=12100 --heartbeat-interval 10000 --screen-ws-url-pattern "wss://${STF_DOMAIN}:${STF_PORT}/d/devicehub-provider/<%= publicPort %>/" --secret=${STF_SECRET} networks: devicehub: devicehub-ssl: diff --git a/eslint.config.js b/eslint.config.js index d2f733b576..e18b828c54 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,44 +11,10 @@ export default tseslint.config( '@typescript-eslint': tseslint.plugin, ascii: { rules: { - 'no-non-ascii': { - meta: { - type: 'problem', - docs: { description: 'Disallow any non-ASCII character anywhere in the file' }, - schema: [], - messages: { - nonAscii: 'Non-ASCII character U+{{code}} found.', - }, - }, - create(context) { - // ESLint 9 exposes SourceCode at context.sourceCode - const sourceCode = context.sourceCode ?? context.getSourceCode?.(); - return { - Program() { - const text = sourceCode.text ?? sourceCode.getText(); - for (let i = 0; i < text.length; i++) { - const cp = text.codePointAt(i); - if (cp > 0x7f) { - const loc = sourceCode.getLocFromIndex(i); - context.report({ - loc: { start: loc, end: { line: loc.line, column: loc.column + 1 } }, - messageId: 'nonAscii', - data: { code: cp.toString(16).toUpperCase().padStart(4, '0') }, - }); - // Skip the second half of a surrogate pair - if (cp > 0xffff) i++; - } - } - }, - }; - }, - }, + 'no-non-ascii': 0, }, }, }, - rules: { - 'ascii/no-non-ascii': 'error', - }, }, { files: ['lib/**/*.js'], diff --git a/helm/devicehub-api-deployment.yaml b/helm/devicehub-api-deployment.yaml index aebd8a5736..c92ec4ead7 100644 --- a/helm/devicehub-api-deployment.yaml +++ b/helm/devicehub-api-deployment.yaml @@ -26,14 +26,8 @@ spec: - api - --port - "3000" - - --connect-sub - - tcp://devicehub-triproxy-app:7150 - - --connect-push - - tcp://devicehub-triproxy-app:7170 - - --connect-sub-dev - - tcp://devicehub-triproxy-dev:7250 - - --connect-push-dev - - tcp://devicehub-triproxy-dev:7270 + - --connect-proxy + - tcp://devicehub-proxy:7110 - --secret= envFrom: - configMapRef: diff --git a/helm/devicehub-api-groups-engine-deployment.yaml b/helm/devicehub-api-groups-engine-deployment.yaml index 054079203f..2ab647e17f 100644 --- a/helm/devicehub-api-groups-engine-deployment.yaml +++ b/helm/devicehub-api-groups-engine-deployment.yaml @@ -24,14 +24,8 @@ spec: - args: - stf - groups-engine - - --connect-sub - - tcp://devicehub-triproxy-app:7150 - - --connect-push - - tcp://devicehub-triproxy-app:7170 - - --connect-sub-dev - - tcp://devicehub-triproxy-dev:7250 - - --connect-push-dev - - tcp://devicehub-triproxy-dev:7270 + - --connect-proxy + - tcp://devicehub-proxy:7110 envFrom: - configMapRef: name: scripts-variables-env diff --git a/helm/devicehub-processor-deployment.yaml b/helm/devicehub-processor-deployment.yaml index fe37a786af..37d271c0d7 100644 --- a/helm/devicehub-processor-deployment.yaml +++ b/helm/devicehub-processor-deployment.yaml @@ -7,8 +7,14 @@ metadata: labels: io.kompose.service: devicehub-processor name: devicehub-processor +# NOTE: helm manifests are kompose-generated and were long unmaintained; this +# is a structural migration to the proxy/processor topology and has NOT been +# validated against a live cluster. Multiple processor replicas each need their +# own device-facing ROUTER identity/endpoint (providers connect per-processor +# via --connect-processor), which a single Service cannot fan out; running more +# than one processor here requires a StatefulSet + headless service. Kept at 1. spec: - replicas: 3 + replicas: 1 selector: matchLabels: io.kompose.service: devicehub-processor @@ -26,21 +32,18 @@ spec: - processor - --name - processor - - --connect-app-dealer - - tcp://devicehub-triproxy-app:7160 - - --connect-dev-dealer - - tcp://devicehub-triproxy-dev:7260 - - --connect-sub - - tcp://devicehub-triproxy-app:7150 - - --connect-push - - tcp://devicehub-triproxy-app:7170 - - --connect-sub-dev - - tcp://devicehub-triproxy-dev:7250 - - --connect-push-dev - - tcp://devicehub-triproxy-dev:7270 + - --connect-proxy + - tcp://devicehub-proxy:7110 + - --bind-router + - tcp://*:7160 + - --heartbeat-timeout + - "30000" envFrom: - configMapRef: name: scripts-variables-env image: vkcom/devicehub name: devicehub-processor + ports: + - containerPort: 7160 + protocol: TCP restartPolicy: Always diff --git a/helm/devicehub-triproxy-app-service.yaml b/helm/devicehub-processor-service.yaml similarity index 50% rename from helm/devicehub-triproxy-app-service.yaml rename to helm/devicehub-processor-service.yaml index fe00876cf7..781a9d40c4 100644 --- a/helm/devicehub-triproxy-app-service.yaml +++ b/helm/devicehub-processor-service.yaml @@ -5,18 +5,12 @@ metadata: kompose.cmd: kompose -f ../docker-compose-prod.yaml convert kompose.version: 1.36.0 (HEAD) labels: - io.kompose.service: devicehub-triproxy-app - name: devicehub-triproxy-app + io.kompose.service: devicehub-processor + name: devicehub-processor spec: ports: - - name: "7150" - port: 7150 - targetPort: 7150 - name: "7160" port: 7160 targetPort: 7160 - - name: "7170" - port: 7170 - targetPort: 7170 selector: - io.kompose.service: devicehub-triproxy-app + io.kompose.service: devicehub-processor diff --git a/helm/devicehub-provider-deployment.yaml b/helm/devicehub-provider-deployment.yaml index d93da99130..ce2f51587e 100644 --- a/helm/devicehub-provider-deployment.yaml +++ b/helm/devicehub-provider-deployment.yaml @@ -29,10 +29,8 @@ spec: - --adb-host - adbd - --no-cleanup - - --connect-sub - - tcp://devicehub-triproxy-dev:7250 - - --connect-push - - tcp://devicehub-triproxy-dev:7270 + - --connect-processor + - tcp://devicehub-processor:7160 - --storage-url - https://:/ - --public-ip diff --git a/helm/devicehub-reaper-deployment.yaml b/helm/devicehub-proxy-deployment.yaml similarity index 56% rename from helm/devicehub-reaper-deployment.yaml rename to helm/devicehub-proxy-deployment.yaml index 30ea4f5386..bda0a5cdc6 100644 --- a/helm/devicehub-reaper-deployment.yaml +++ b/helm/devicehub-proxy-deployment.yaml @@ -5,36 +5,34 @@ metadata: kompose.cmd: kompose -f ../docker-compose-prod.yaml convert kompose.version: 1.36.0 (HEAD) labels: - io.kompose.service: devicehub-reaper - name: devicehub-reaper + io.kompose.service: devicehub-proxy + name: devicehub-proxy spec: replicas: 1 selector: matchLabels: - io.kompose.service: devicehub-reaper + io.kompose.service: devicehub-proxy template: metadata: annotations: kompose.cmd: kompose -f ../docker-compose-prod.yaml convert kompose.version: 1.36.0 (HEAD) labels: - io.kompose.service: devicehub-reaper + io.kompose.service: devicehub-proxy spec: containers: - args: - stf - - reaper - - --name - - reaper001 - - --connect-push - - tcp://devicehub-triproxy-dev:7270 - - --connect-sub - - tcp://devicehub-triproxy-app:7150 - - --heartbeat-timeout - - "30000" + - proxy + - proxy001 + - --bind-router + - tcp://*:7110 envFrom: - configMapRef: name: scripts-variables-env image: vkcom/devicehub - name: devicehub-reaper + name: devicehub-proxy + ports: + - containerPort: 7110 + protocol: TCP restartPolicy: Always diff --git a/helm/devicehub-proxy-service.yaml b/helm/devicehub-proxy-service.yaml new file mode 100644 index 0000000000..8fe4b568fd --- /dev/null +++ b/helm/devicehub-proxy-service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + annotations: + kompose.cmd: kompose -f ../docker-compose-prod.yaml convert + kompose.version: 1.36.0 (HEAD) + labels: + io.kompose.service: devicehub-proxy + name: devicehub-proxy +spec: + ports: + - name: "7110" + port: 7110 + targetPort: 7110 + selector: + io.kompose.service: devicehub-proxy diff --git a/helm/devicehub-storage-temp-deployment.yaml b/helm/devicehub-storage-temp-deployment.yaml index 1a4235441f..77480bb772 100644 --- a/helm/devicehub-storage-temp-deployment.yaml +++ b/helm/devicehub-storage-temp-deployment.yaml @@ -28,10 +28,6 @@ spec: - "3000" - --save-dir - /tmp - - --connect-sub - - tcp://devicehub-triproxy-app:7150 - - --connect-push - - tcp://devicehub-triproxy-app:7170 - --secret= envFrom: - configMapRef: diff --git a/helm/devicehub-triproxy-app-deployment.yaml b/helm/devicehub-triproxy-app-deployment.yaml deleted file mode 100644 index 422c257960..0000000000 --- a/helm/devicehub-triproxy-app-deployment.yaml +++ /dev/null @@ -1,46 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - kompose.cmd: kompose -f ../docker-compose-prod.yaml convert - kompose.version: 1.36.0 (HEAD) - labels: - io.kompose.service: devicehub-triproxy-app - name: devicehub-triproxy-app -spec: - replicas: 1 - selector: - matchLabels: - io.kompose.service: devicehub-triproxy-app - template: - metadata: - annotations: - kompose.cmd: kompose -f ../docker-compose-prod.yaml convert - kompose.version: 1.36.0 (HEAD) - labels: - io.kompose.service: devicehub-triproxy-app - spec: - containers: - - args: - - stf - - triproxy - - app001 - - --bind-pub - - tcp://*:7150 - - --bind-dealer - - tcp://*:7160 - - --bind-pull - - tcp://*:7170 - envFrom: - - configMapRef: - name: scripts-variables-env - image: vkcom/devicehub - name: devicehub-triproxy-app - ports: - - containerPort: 7150 - protocol: TCP - - containerPort: 7160 - protocol: TCP - - containerPort: 7170 - protocol: TCP - restartPolicy: Always diff --git a/helm/devicehub-triproxy-dev-deployment.yaml b/helm/devicehub-triproxy-dev-deployment.yaml deleted file mode 100644 index 11a3bbefe6..0000000000 --- a/helm/devicehub-triproxy-dev-deployment.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - kompose.cmd: kompose -f ../docker-compose-prod.yaml convert - kompose.version: 1.36.0 (HEAD) - labels: - io.kompose.service: devicehub-triproxy-dev - name: devicehub-triproxy-dev -spec: - replicas: 1 - selector: - matchLabels: - io.kompose.service: devicehub-triproxy-dev - template: - metadata: - annotations: - kompose.cmd: kompose -f ../docker-compose-prod.yaml convert - kompose.version: 1.36.0 (HEAD) - labels: - io.kompose.service: devicehub-triproxy-dev - spec: - containers: - - args: - - stf - - triproxy - - --name - - dev001 - - --bind-pub - - tcp://*:7250 - - --bind-dealer - - tcp://*:7260 - - --bind-pull - - tcp://*:7270 - envFrom: - - configMapRef: - name: scripts-variables-env - image: vkcom/devicehub - name: devicehub-triproxy-dev - ports: - - containerPort: 7250 - protocol: TCP - - containerPort: 7260 - protocol: TCP - - containerPort: 7270 - protocol: TCP - restartPolicy: Always diff --git a/helm/devicehub-triproxy-dev-service.yaml b/helm/devicehub-triproxy-dev-service.yaml deleted file mode 100644 index ec0d4b9af0..0000000000 --- a/helm/devicehub-triproxy-dev-service.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - annotations: - kompose.cmd: kompose -f ../docker-compose-prod.yaml convert - kompose.version: 1.36.0 (HEAD) - labels: - io.kompose.service: devicehub-triproxy-dev - name: devicehub-triproxy-dev -spec: - ports: - - name: "7250" - port: 7250 - targetPort: 7250 - - name: "7260" - port: 7260 - targetPort: 7260 - - name: "7270" - port: 7270 - targetPort: 7270 - selector: - io.kompose.service: devicehub-triproxy-dev diff --git a/helm/devicehub-websocket-deployment.yaml b/helm/devicehub-websocket-deployment.yaml index be5010ec39..b5b3cb109e 100644 --- a/helm/devicehub-websocket-deployment.yaml +++ b/helm/devicehub-websocket-deployment.yaml @@ -28,14 +28,8 @@ spec: - "3000" - --storage-url - https://:/ - - --connect-sub - - tcp://devicehub-triproxy-app:7150 - - --connect-push - - tcp://devicehub-triproxy-app:7170 - - --connect-sub-dev - - tcp://devicehub-triproxy-dev:7250 - - --connect-push-dev - - tcp://devicehub-triproxy-dev:7270 + - --connect-proxy + - tcp://devicehub-proxy:7110 - --secret= envFrom: - configMapRef: diff --git a/lib/cli/api/index.js b/lib/cli/api/index.js index 0bce3f6d7e..ca63470539 100644 --- a/lib/cli/api/index.js +++ b/lib/cli/api/index.js @@ -3,29 +3,10 @@ export const command = 'api' export const describe = 'Start an API unit.' export const builder = function(yargs) { return yargs - .env('STF_API') - .strict() - .option('connect-push', { + .env('DH') + .option('connect-proxy', { alias: 'c', - describe: 'App-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 'u', - describe: 'App-side ZeroMQ PUB endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-push-dev', { - alias: 'pd', - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub-dev', { - alias: 'sd', - describe: 'Device-side ZeroMQ PUB endpoint to connect to.', + describe: 'Proxy ROUTER endpoint(s) to connect to.', array: true, demand: true }) @@ -67,10 +48,7 @@ export const handler = function(argv) { secret: argv.secret, storageUrl: argv.storageUrl, endpoints: { - push: argv.connectPush, - sub: argv.connectSub, - pushdev: argv.connectPushDev, - subdev: argv.connectSubDev + proxy: argv.connectProxy } }) } diff --git a/lib/cli/app/index.js b/lib/cli/app/index.js index 52da4567f7..513d2600b6 100644 --- a/lib/cli/app/index.js +++ b/lib/cli/app/index.js @@ -3,8 +3,7 @@ export const command = 'app' export const describe = 'Start an app unit.' export const builder = function(yargs) { return yargs - .env('STF_APP') - .strict() + .env('DH') .option('auth-url', { alias: 'a', describe: 'URL to the auth unit.', diff --git a/lib/cli/auth-ldap/index.js b/lib/cli/auth-ldap/index.js index c01de34a78..dca19526ac 100644 --- a/lib/cli/auth-ldap/index.js +++ b/lib/cli/auth-ldap/index.js @@ -3,8 +3,7 @@ export const command = 'auth-ldap' export const describe = 'Start an LDAP auth unit.' export const builder = function(yargs) { return yargs - .env('STF_AUTH_LDAP') - .strict() + .env('DH') .option('app-url', { alias: 'a', describe: 'URL to the app unit.', diff --git a/lib/cli/auth-mock/index.js b/lib/cli/auth-mock/index.js index e85c1242ac..172942af52 100644 --- a/lib/cli/auth-mock/index.js +++ b/lib/cli/auth-mock/index.js @@ -3,8 +3,7 @@ export const command = 'auth-mock' export const describe = 'Start a mock auth unit that accepts any user.' export const builder = function(yargs) { return yargs - .env('STF_AUTH_MOCK') - .strict() + .env('DH') .option('app-url', { alias: 'a', describe: 'URL to the app unit.', diff --git a/lib/cli/auth-oauth2/index.js b/lib/cli/auth-oauth2/index.js index 5ab913b744..ed4f5dbb0f 100644 --- a/lib/cli/auth-oauth2/index.js +++ b/lib/cli/auth-oauth2/index.js @@ -3,8 +3,7 @@ export const command = 'auth-oauth2' export const describe = 'Start an OAuth 2.0 auth unit.' export const builder = function(yargs) { return yargs - .env('STF_AUTH_OAUTH2') - .strict() + .env('DH') .option('app-url', { alias: 'a', describe: 'URL to the app unit.', diff --git a/lib/cli/auth-openid/index.js b/lib/cli/auth-openid/index.js index cdc6e19b88..5253a61b51 100644 --- a/lib/cli/auth-openid/index.js +++ b/lib/cli/auth-openid/index.js @@ -3,8 +3,7 @@ export const command = 'auth-openid' export const describe = 'Start an OpenID auth unit.' export const builder = function(yargs) { return yargs - .env('STF_AUTH_OPENID') - .strict() + .env('DH') .option('app-url', { alias: 'a', describe: 'URL to the app unit.', diff --git a/lib/cli/auth-saml2/index.js b/lib/cli/auth-saml2/index.js index 9514aa46fa..aea35fdcd7 100644 --- a/lib/cli/auth-saml2/index.js +++ b/lib/cli/auth-saml2/index.js @@ -3,8 +3,7 @@ export const command = 'auth-saml2' export const describe = 'Start a SAML 2.0 auth unit.' export const builder = function(yargs) { return yargs - .env('STF_AUTH_SAML2') - .strict() + .env('DH') .option('app-url', { alias: 'a', describe: 'URL to the app unit.', diff --git a/lib/cli/device/index.js b/lib/cli/device/index.js index f8d1069ffb..d9f3579d06 100644 --- a/lib/cli/device/index.js +++ b/lib/cli/device/index.js @@ -3,7 +3,6 @@ import ip from 'my-local-ip' export const command = 'device' export const builder = function(yargs) { return yargs - .strict() .option('adb-host', { describe: 'The ADB server host.', type: 'string', @@ -41,15 +40,9 @@ export const builder = function(yargs) { type: 'number', demand: true }) - .option('connect-push', { + .option('connect-processor', { alias: 'p', - describe: 'ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'ZeroMQ PUB endpoint to connect to.', + describe: 'Processor ZeroMQ ROUTER endpoint to connect to.', array: true, demand: true }) @@ -209,8 +202,7 @@ export const handler = function(argv) { provider: argv.provider, publicIp: argv.publicIp, endpoints: { - sub: argv.connectSub, - push: argv.connectPush + processor: argv.connectProcessor }, groupTimeout: argv.groupTimeout * 1000, // change to ms storageUrl: argv.storageUrl, diff --git a/lib/cli/generate-fake-device/index.js b/lib/cli/generate-fake-device/index.js index abf613b0b7..3bc7f3f486 100644 --- a/lib/cli/generate-fake-device/index.js +++ b/lib/cli/generate-fake-device/index.js @@ -6,7 +6,6 @@ export const command = 'generate-fake-device ' export const builder = function(yargs) { return yargs - .strict() .option('number', { alias: 'n', describe: 'How many devices to create.', diff --git a/lib/cli/generate-fake-group/index.js b/lib/cli/generate-fake-group/index.js index 15e70500bd..983f74e84c 100644 --- a/lib/cli/generate-fake-group/index.js +++ b/lib/cli/generate-fake-group/index.js @@ -3,7 +3,6 @@ import * as fake from '../../util/fakegroup.js' export const command = 'generate-fake-group' export const builder = function(yargs) { return yargs - .strict() .option('n', { alias: 'number', describe: 'How many groups to create.', diff --git a/lib/cli/generate-fake-user/index.js b/lib/cli/generate-fake-user/index.js index 1f6e78686d..5a4e1145f5 100644 --- a/lib/cli/generate-fake-user/index.js +++ b/lib/cli/generate-fake-user/index.js @@ -3,7 +3,6 @@ import * as fake from '../../util/fakeuser.js' export const command = 'generate-fake-user' export const builder = function(yargs) { return yargs - .strict() .option('n', { alias: 'number', describe: 'How many users to create.', diff --git a/lib/cli/generate-service-user/index.js b/lib/cli/generate-service-user/index.js index cf14c624f1..fcf911d88d 100644 --- a/lib/cli/generate-service-user/index.js +++ b/lib/cli/generate-service-user/index.js @@ -3,7 +3,6 @@ import * as service from '../../util/serviceuser.js' export const command = 'generate-service-user' export const builder = function(yargs) { return yargs - .strict() .option('admin', { alias: 'admin', describe: 'user need to be admin', diff --git a/lib/cli/groups-engine/index.js b/lib/cli/groups-engine/index.js index e523713502..57f34d5cb5 100644 --- a/lib/cli/groups-engine/index.js +++ b/lib/cli/groups-engine/index.js @@ -3,17 +3,10 @@ export const command = 'groups-engine' export const describe = 'Start the groups engine unit.' export const builder = function(yargs) { return yargs - .env('STF_GROUPS_ENGINE') - .strict() - .option('connect-push', { + .env('DH') + .option('connect-proxy', { alias: 'c', - describe: 'App-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-push-dev', { - alias: 'pd', - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', + describe: 'Proxy ROUTER endpoint(s) to connect to.', array: true, demand: true }) @@ -24,8 +17,7 @@ export const builder = function(yargs) { export const handler = function(argv) { return groupsEngine({ endpoints: { - push: argv.connectPush, - pushdev: argv.connectPushDev, + proxy: argv.connectProxy } }) } diff --git a/lib/cli/index.js b/lib/cli/index.js index 6021c158e2..fa0f37c06b 100644 --- a/lib/cli/index.js +++ b/lib/cli/index.js @@ -21,25 +21,48 @@ import * as generateFakeUser from './generate-fake-user/index.js' import * as generateFakeGroup from './generate-fake-group/index.js' import * as generateServiceUser from './generate-service-user/index.js' import * as local from './local/index.js' -import * as logMongodb from './log-mongodb/index.js' import * as migrate from './migrate/index.js' import * as migrateToMongo from './migrate-to-mongo/index.js' import * as poorxy from './poorxy/index.js' import * as processor from './processor/index.js' import * as provider from './provider/index.js' import * as iosProvider from './ios-provider/index.js' -import * as reaper from './reaper/index.js' import * as storagePluginApk from './storage-plugin-apk/index.js' import * as storagePluginImage from './storage-plugin-image/index.js' import * as storageS3 from './storage-s3/index.js' import * as storageTemp from './storage-temp/index.js' -import * as triproxy from './triproxy/index.js' +import * as proxy from './proxy/index.js' import * as websocket from './websocket/index.js' import * as tizenDevice from './tizen-device/index.js' import globalLogger from '../util/logger.js' yargs(hideBin(process.argv)).usage('Usage: $0 [options]') .strict() + .fail((msg, err) => { + if (!msg || !msg?.includes('Unknown argument')) { + throw err || new Error(msg) + } + + const foundKeys = msg.replace(/Unknown argument(s)?: /, '').split(', ') + + const realBadCliArgs = foundKeys.filter(key => + process.argv.slice(2).some(arg => + arg === `--${key}` || + arg.startsWith(`--${key}=`) || + arg === `-${key}` + ) + ) + + if (realBadCliArgs.length === 0) { + return + } + + const finalMsg = realBadCliArgs.length === 1 ? + `Unknown argument: ${realBadCliArgs[0]}` : + `Unknown arguments: ${realBadCliArgs.join(', ')}` + + throw new Error(finalMsg) + }) .command(api) .command(app) .command(authLdap) @@ -58,19 +81,17 @@ yargs(hideBin(process.argv)).usage('Usage: $0 [options]') .command(generateFakeGroup) .command(generateServiceUser) .command(local) - .command(logMongodb) .command(migrate) .command(migrateToMongo) .command(poorxy) .command(processor) .command(provider) .command(iosProvider) - .command(reaper) .command(storagePluginApk) .command(storagePluginImage) .command(storageS3) .command(storageTemp) - .command(triproxy) + .command(proxy) .command(websocket) .demandCommand(1, 'Must provide a valid command.') .help('h', 'Show help.') diff --git a/lib/cli/ios-device/index.js b/lib/cli/ios-device/index.js index 799639f7ab..95b9fa3fda 100644 --- a/lib/cli/ios-device/index.js +++ b/lib/cli/ios-device/index.js @@ -3,7 +3,6 @@ export const command = 'ios-device' export const describe = 'Start an ios device provider.' export const builder = function(yargs) { return yargs - .strict() .option('cleanup', { describe: 'Attempt to reset the device between uses by uninstalling' + 'apps, resetting accounts and clearing caches. Does not do a perfect ' + @@ -11,15 +10,9 @@ export const builder = function(yargs) { type: 'boolean', default: true }) - .option('connect-push', { + .option('connect-processor', { alias: 'p', - describe: 'ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'ZeroMQ PUB endpoint to connect to.', + describe: 'Processor ZeroMQ ROUTER endpoint to connect to.', array: true, demand: true }) @@ -156,8 +149,7 @@ export const handler = function(argv) { provider: argv.provider, publicIp: argv.publicIp, endpoints: { - sub: argv.connectSub.filter(e => !!e.trim()), - push: argv.connectPush.filter(e => !!e.trim()) + processor: argv.connectProcessor.filter(e => !!e.trim()) }, groupTimeout: argv.groupTimeout * 1000, // change to ms storageUrl: argv.storageUrl, diff --git a/lib/cli/ios-device/start-ios.sh b/lib/cli/ios-device/start-ios.sh index c19d0458bd..e881248b6d 100755 --- a/lib/cli/ios-device/start-ios.sh +++ b/lib/cli/ios-device/start-ios.sh @@ -9,7 +9,6 @@ stf ios-device \ --public-ip localhost \ --screen-ws-url-pattern "ws://localhost:7409" \ --storage-url http://localhost:7100/ \ - --connect-sub tcp://127.0.0.1:7114 \ - --connect-push tcp://127.0.0.1:7116 \ + --connect-processor tcp://127.0.0.1:7160 \ --wda-host 127.0.0.1 \ --wda-port 8100 diff --git a/lib/cli/ios-provider/index.js b/lib/cli/ios-provider/index.js index a0bfb9e8cb..e02d1bc190 100644 --- a/lib/cli/ios-provider/index.js +++ b/lib/cli/ios-provider/index.js @@ -6,7 +6,6 @@ export const command = 'ios-provider [serial..]' export const describe = 'Start an ios-provider unit.' export const builder = function(yargs) { return yargs - .strict() .env('STF_PROVIDER') .option('usbmux-path', { describe: 'Path to usbmux sock file. (note: doesnt do anything at the moment)', @@ -56,15 +55,9 @@ export const builder = function(yargs) { type: 'number', default: 18300 }) - .option('connect-push', { + .option('connect-processor', { alias: 'p', - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'Device-side ZeroMQ PUB endpoint to connect to.', + describe: 'Processor ZeroMQ ROUTER endpoint the forked ios-devices connect to.', array: true, demand: true }) @@ -156,8 +149,7 @@ export const handler = function(argv) { argv.serial = argv.serial.filter(s => !!s.trim().length) } - argv.connectPush = argv.connectPush.filter(s => !!s.trim().length) - argv.connectSub = argv.connectSub.filter(s => !!s.trim().length) + argv.connectProcessor = argv.connectProcessor.filter(s => !!s.trim().length) const cli = path.resolve(import.meta.dirname, '..') const allPorts = _.range(argv.portRangeMin, argv.portRangeMax) @@ -176,8 +168,7 @@ export const handler = function(argv) { killTimeout: 30000, publicIp: argv.publicIp, endpoints: { - push: argv.connectPush, - sub: argv.connectSub + processor: argv.connectProcessor }, fork: (serial, opts) => { const args = [ @@ -200,8 +191,7 @@ export const handler = function(argv) { '--secret', argv.secret, '--is-simulator', opts.isSimulator + '', - ... argv.connectSub.reduce((all, val) => all.concat(['--connect-sub', val]), []), - ... argv.connectPush.reduce((all, val) => all.concat(['--connect-push', val]), []), + ... argv.connectProcessor.reduce((all, val) => all.concat(['--connect-processor', val]), []), ... (opts.isSimulator ? ['--is-simulator', 'true'] : []), diff --git a/lib/cli/local/index.js b/lib/cli/local/index.js index 2eed033d86..3876fe8ee2 100644 --- a/lib/cli/local/index.js +++ b/lib/cli/local/index.js @@ -8,8 +8,7 @@ export const command = 'local [serial..]' export const describe = 'Start a complete local development environment.' export const builder = function(yargs) { return yargs - .env('STF_LOCAL') - .strict() + .env('DH') .option('adb-host', { describe: 'The ADB server host.', type: 'string', @@ -80,60 +79,18 @@ export const builder = function(yargs) { describe: 'URL to the auth unit.', type: 'string' }) - .option('bind-app-dealer', { - describe: 'The address to bind the app-side ZeroMQ DEALER endpoint to.', + .option('bind-proxy-router', { + describe: 'The address to bind the proxy ZeroMQ ROUTER endpoint to ' + + '(the single public entrypoint for all app-side units). NOTE: must not ' + + 'collide with the websocket HTTP port (7110) — 7150 is used here.', type: 'string', - default: 'tcp://127.0.0.1:7112' + default: 'tcp://127.0.0.1:7150' }) - .option('bind-app-pub', { - describe: 'The address to bind the app-side ZeroMQ PUB endpoint to.', + .option('bind-processor-router', { + describe: 'The address to bind the processor device-facing ZeroMQ ' + + 'ROUTER endpoint to (providers/devices connect here).', type: 'string', - default: 'tcp://127.0.0.1:7111' - }) - .option('bind-app-pull', { - describe: 'The address to bind the app-side ZeroMQ PULL endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7113' - }) - .option('bind-dev-dealer', { - describe: 'The address to bind the device-side ZeroMQ DEALER endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7115' - }) - .option('bind-dev-pub', { - describe: 'The address to bind the device-side ZeroMQ PUB endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7114' - }) - .option('bind-dev-pull', { - describe: 'The address to bind the device-side ZeroMQ PULL endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7116' - }) - .option('bind-ios-pub', { - describe: 'The address to bind the app-side ZeroMQ PUB endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7401' - }) - .option('bind-ios-pull', { - describe: 'The address to bind the app-side ZeroMQ PULL endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7400' - }) - .option('bind-temp-pull', { - descrive: 'The address to bind the app-side ZeroMQ PULL endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7116' - }) - .option('bind-processor-pull', { - descrive: 'The address to bind the app-side ZeroMQ PULL endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7117' - }) - .option('bind-temp-pub', { - describe: 'The address to bind the app-side ZeroMQ PUB endpoint to.', - type: 'string', - default: 'tcp://127.0.0.1:7407' + default: 'tcp://127.0.0.1:7160' }) .option('cleanup', { describe: 'Attempt to reset the device between uses by uninstalling' + @@ -302,15 +259,15 @@ export const builder = function(yargs) { type: 'boolean', default: true }) - .option('time-to-device-cleanup', { - describe: 'Time in seconds after which connected devices should be deleted. 0 - do not delete', - type: 'number', - default: 0 + .option('without-provider', { + describe: 'Start without provider unit', + type: 'boolean', + default: false }) - .option('device-cleanup-interval', { - describe: 'Interval for checking devices for cleanup in minutes', - type: 'number', - default: 120_000 + .option('without-poorxy', { + describe: 'Start without poorxy unit', + type: 'boolean', + default: false }) .epilog('Each option can be be overwritten with an environment variable ' + 'by converting the option to uppercase, replacing dashes with ' + @@ -330,60 +287,18 @@ export const handler = function(argv) { ] ], [ // stage 1 - [ // app triproxy - 'triproxy', 'app001', - '--bind-pub', argv.bindAppPub, - '--bind-dealer', argv.bindAppDealer, - '--bind-pull', argv.bindAppPull - ], - [ // device triproxy - 'triproxy', 'dev001', - '--bind-pub', argv.bindDevPub, - '--bind-dealer', argv.bindDevDealer, - '--bind-pull', argv.bindDevPull + [ // proxy (single ROUTER: the public entrypoint for all app-side units) + 'proxy', 'proxy001', + '--bind-router', argv.bindProxyRouter ] ], [ // stage 2 - [ // processor one + [ // processor one: DEALER -> proxy, ROUTER <- providers/devices 'processor', 'proc001', - '--connect-app-dealer', argv.bindAppDealer, - '--connect-dev-dealer', argv.bindDevDealer, + '--connect-proxy', argv.bindProxyRouter, + '--bind-router', argv.bindProcessorRouter, '--public-ip', argv.publicIp ], - [ // reaper one - 'reaper', 'reaper001', - '--connect-push', argv.bindDevPull, - '--connect-sub', argv.bindDevPub, - '--time-to-device-cleanup', argv.timeToDeviceCleanup, - '--device-cleanup-interval', argv.deviceCleanupInterval - ], - [ // provider - 'provider', - '--name', argv.provider, - '--min-port', argv.providerMinPort, - '--max-port', argv.providerMaxPort, - '--connect-sub', argv.bindDevPub, - '--connect-push', argv.bindDevPull, - '--group-timeout', argv.groupTimeout, - '--public-ip', argv.publicIp, - '--storage-url', - util.format('http://localhost:%d/', argv.port), - '--adb-host', argv.adbHost, - '--adb-port', argv.adbPort, - '--vnc-initial-size', argv.vncInitialSize.join('x'), - '--mute-master', argv.muteMaster, - '--need-scrcpy', argv.needScrcpy, - '--screen-ws-url-pattern', argv.screenWsUrlPattern, - '--url-without-adb-port', argv.urlWithoutAdbPort, - '--secret', argv.authSecret - ] - .concat(argv.allowRemote ? ['--allow-remote'] : []) - .concat(argv.lockRotation ? ['--lock-rotation'] : []) - .concat(!argv.cleanup ? ['--no-cleanup'] : []) - .concat(argv.cleanupDisableBluetooth ? ['--cleanup-disable-bluetooth'] : []) - .concat(argv.cleanupBluetoothBonds ? ['--cleanup-bluetooth-bonds'] : []) - .concat(!argv.screenReset ? ['--no-screen-reset'] : []) - .concat(argv.serial ?? []), [ // auth util.format('auth-%s', argv.authType), '--port', argv.authPort, @@ -406,39 +321,30 @@ export const handler = function(argv) { } return extra })()), - [ // api + [ // api: single DEALER -> proxy 'api', '--port', argv.apiPort, '--secret', argv.authSecret, - '--connect-push', argv.bindAppPull, - '--connect-sub', argv.bindAppPub, - '--connect-push-dev', argv.bindDevPull, - '--connect-sub-dev', argv.bindDevPub, + '--connect-proxy', argv.bindProxyRouter, '--storage-url', util.format('http://localhost:%d', argv.port) ], - [ // groups engine + [ // groups engine: single DEALER -> proxy 'groups-engine', - '--connect-push', argv.bindAppPull, - '--connect-push-dev', argv.bindDevPull, + '--connect-proxy', argv.bindProxyRouter ], - [ // websocket + [ // websocket: single DEALER -> proxy (broadcast receiver) 'websocket', '--port', argv.websocketPort, '--secret', argv.authSecret, '--storage-url', util.format('http://localhost:%d/', argv.port), - '--connect-push', argv.bindAppPull, - '--connect-push-dev', argv.bindDevPull, - '--connect-sub', argv.bindAppPub, - '--connect-sub-dev', argv.bindDevPub + '--connect-proxy', argv.bindProxyRouter ], [ // storage util.format('storage-%s', argv.storageType), '--port', argv.storagePort, - '--secret', argv.authSecret, - '--connect-push', argv.bindTempPull, - '--connect-sub', argv.bindTempPub + '--secret', argv.authSecret ].concat(JSON.parse(argv.storageOptions)), [ // image processor 'storage-plugin-image', @@ -454,8 +360,42 @@ export const handler = function(argv) { util.format('http://localhost:%d/', argv.storagePort), '--secret', argv.authSecret ] - ], - [ // stage 3 + ] + ] + + if (!argv.withoutProvider) { + stages[stages.length - 1].push( + [ // provider + 'provider', + '--name', argv.provider, + '--min-port', argv.providerMinPort, + '--max-port', argv.providerMaxPort, + '--connect-processor', argv.bindProcessorRouter, + '--group-timeout', argv.groupTimeout, + '--public-ip', argv.publicIp, + '--storage-url', + util.format('http://localhost:%d/', argv.port), + '--adb-host', argv.adbHost, + '--adb-port', argv.adbPort, + '--vnc-initial-size', argv.vncInitialSize.join('x'), + '--mute-master', argv.muteMaster, + '--need-scrcpy', argv.needScrcpy, + '--screen-ws-url-pattern', argv.screenWsUrlPattern, + '--url-without-adb-port', argv.urlWithoutAdbPort, + '--secret', argv.authSecret + ] + .concat(argv.allowRemote ? ['--allow-remote'] : []) + .concat(argv.lockRotation ? ['--lock-rotation'] : []) + .concat(!argv.cleanup ? ['--no-cleanup'] : []) + .concat(argv.cleanupDisableBluetooth ? ['--cleanup-disable-bluetooth'] : []) + .concat(argv.cleanupBluetoothBonds ? ['--cleanup-bluetooth-bonds'] : []) + .concat(!argv.screenReset ? ['--no-screen-reset'] : []) + .concat(argv.serial ?? []), + ) + } + + if (!argv.withoutPoorxy) { + stages[stages.length - 1].push( [ // poorxy 'poorxy', '--port', argv.port, @@ -474,8 +414,9 @@ export const handler = function(argv) { '--storage-plugin-apk-url', util.format('http://localhost:%d/', argv.storagePluginApkPort) ] - ] - ] + ) + } + async function run() { let procs = [] function shutdown() { diff --git a/lib/cli/log-mongodb/index.js b/lib/cli/log-mongodb/index.js deleted file mode 100644 index ad33d2eb4a..0000000000 --- a/lib/cli/log-mongodb/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import logger from '../../util/logger.js' -import mongodb from '../../units/log/mongodb.js' -export const command = 'log-mongodb' -export const describe = 'Start a MongoDB log unit.' -export const builder = function(yargs) { - return yargs - .env('STF_LOG_MONGODB') - .strict() - .option('connect-sub', { - alias: 's', - describe: 'App-side ZeroMQ PUB endpoint to connect to.', - array: true, - demand: true - }) - .option('priority', { - alias: 'p', - describe: 'Minimum log level.', - type: 'number', - default: logger.Level.IMPORTANT - }) - .epilog('Each option can be be overwritten with an environment variable ' + - 'by converting the option to uppercase, replacing dashes with ' + - 'underscores and prefixing it with `STF_LOG_MONGODB_` (e.g. ' + - '`STF_LOG_MONGODB_PRIORITY`).') -} -export const handler = function(argv) { - return mongodb({ - priority: argv.priority, - endpoints: { - sub: argv.connectSub - } - }) -} diff --git a/lib/cli/poorxy/index.js b/lib/cli/poorxy/index.js index 89f5389d36..912433518f 100644 --- a/lib/cli/poorxy/index.js +++ b/lib/cli/poorxy/index.js @@ -2,8 +2,7 @@ import poorxy from '../../units/poorxy/index.js' export const command = 'poorxy' export const builder = function(yargs) { return yargs - .strict() - .env('STF_POORXY') + .env('DH') .option('api-url', { alias: 'i', describe: 'URL to the api unit.', diff --git a/lib/cli/processor/index.js b/lib/cli/processor/index.js index a420e9e587..e9c278898e 100644 --- a/lib/cli/processor/index.js +++ b/lib/cli/processor/index.js @@ -4,43 +4,49 @@ export const command = 'processor [name]' export const describe = 'Start a processor unit.' export const builder = function(yargs) { return yargs - .env('STF_PROCESSOR') + .env('DH') .strict() - .option('connect-app-dealer', { - alias: 'a', - describe: 'App-side ZeroMQ DEALER endpoint to connect to.', + .option('connect-proxy', { + alias: 'p', + describe: 'Proxy ZeroMQ DEALER endpoint to connect to.', array: true, demand: true }) - .option('connect-dev-dealer', { - alias: 'd', - describe: 'Device-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true + .option('bind-router', { + alias: 'r', + describe: 'The address to bind the device-facing ZeroMQ ROUTER endpoint to.', + type: 'string', + default: 'tcp://*:7160' }) .option('name', { - describe: 'An easily identifiable name for log output.', + describe: 'An easily identifiable name for log output and the DEALER routing id.', type: 'string', default: os.hostname() }) + .option('heartbeat-timeout', { + describe: 'How long to wait for a heartbeat (in milliseconds) until marking a device absent.', + type: 'number', + default: 30000 + }) .option('public-ip', { alias: 'pi', - description: 'Defined public ip for stf', + describe: 'Defined public ip for stf', type: 'string', default: 'localhost' }) .epilog('Each option can be be overwritten with an environment variable ' + 'by converting the option to uppercase, replacing dashes with ' + - 'underscores and prefixing it with `STF_PROCESSOR_` (e.g. ' + - '`STF_PROCESSOR_CONNECT_APP_DEALER`).') + 'underscores and prefixing it with `DH_PROCESSOR_` (e.g. ' + + '`DH_PROCESSOR_CONNECT_PROXY`).') } export const handler = function(argv) { return processor({ name: argv.name, endpoints: { - appDealer: argv.connectAppDealer, - devDealer: argv.connectDevDealer, + proxy: argv.connectProxy, + deviceRouter: argv.bindRouter, }, + heartbeatTimeout: argv.heartbeatTimeout, publicIp: argv.publicIp }) } diff --git a/lib/cli/provider/index.js b/lib/cli/provider/index.js index 640d59efe3..3e4032ebd9 100644 --- a/lib/cli/provider/index.js +++ b/lib/cli/provider/index.js @@ -7,8 +7,7 @@ export const command = 'provider [serial..]' export const describe = 'Start a provider unit.' export const builder = function(yargs) { return yargs - .strict() - .env('STF_PROVIDER') + .env('DH') .option('adb-host', { describe: 'The ADB server host.', type: 'string', @@ -49,15 +48,9 @@ export const builder = function(yargs) { type: 'boolean', default: false }) - .option('connect-push', { + .option('connect-processor', { alias: 'p', - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'Device-side ZeroMQ PUB endpoint to connect to.', + describe: 'Processor ZeroMQ ROUTER endpoint the forked devices connect to.', array: true, demand: true }) @@ -239,7 +232,7 @@ export const handler = function(argv) { return argv.serial === undefined || argv.serial.length === 0 || argv.serial.indexOf(serial) !== -1 }, allowRemote: argv.allowRemote, - fork: function(serial, ports) { + fork: function(serial, ports, processorEndpoint) { var args = [ 'device', '--serial', serial, @@ -268,14 +261,9 @@ export const handler = function(argv) { '--url-without-adb-port', argv.urlWithoutAdbPort, '--device-code', argv.deviceCode, '--secret', argv.secret, - '--disable-logs-over-wire', argv.disableLogsOverWire + '--disable-logs-over-wire', argv.disableLogsOverWire, + '--connect-processor', processorEndpoint ] - .concat(argv.connectSub.reduce(function(all, val) { - return all.concat(['--connect-sub', val]) - }, [])) - .concat(argv.connectPush.reduce(function(all, val) { - return all.concat(['--connect-push', val]) - }, [])) .concat(argv.lockRotation ? ['--lock-rotation'] : []) .concat(!argv.cleanup ? ['--no-cleanup'] : []) .concat(argv.cleanupDisableBluetooth ? ['--cleanup-disable-bluetooth'] : []) @@ -284,8 +272,7 @@ export const handler = function(argv) { return fork(cli, args) }, endpoints: { - sub: argv.connectSub, - push: argv.connectPush + processor: argv.connectProcessor }, adbHost: argv.adbHost, adbPort: argv.adbPort, diff --git a/lib/cli/proxy/index.js b/lib/cli/proxy/index.js new file mode 100644 index 0000000000..0dfb1303a7 --- /dev/null +++ b/lib/cli/proxy/index.js @@ -0,0 +1,31 @@ +import os from 'os' +import proxy from '../../units/proxy/index.js' +export const command = 'proxy [name]' +export const describe = 'Start a proxy unit.' +export const builder = function(yargs) { + return yargs + .env('DH') + .option('bind-router', { + alias: 'r', + describe: 'The address to bind the ZeroMQ ROUTER endpoint to.', + type: 'string', + default: 'tcp://*:7110' + }) + .option('name', { + describe: 'An easily identifiable name for log output.', + type: 'string', + default: os.hostname() + }) + .epilog('Each option can be be overwritten with an environment variable ' + + 'by converting the option to uppercase, replacing dashes with ' + + 'underscores and prefixing it with `DH_PROXY_` (e.g. ' + + '`DH_PROXY_BIND_ROUTER`).') +} +export const handler = function(argv) { + return proxy({ + name: argv.name, + endpoints: { + router: argv.bindRouter + } + }) +} diff --git a/lib/cli/reaper/index.js b/lib/cli/reaper/index.js deleted file mode 100644 index b741bc2214..0000000000 --- a/lib/cli/reaper/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import os from 'os' -import reaper from '../../units/reaper/index.js' -export const command = 'reaper [name]' -export const describe = 'Start a reaper unit.' -export const builder = function(yargs) { - return yargs - .env('STF_REAPER') - .strict() - .option('connect-push', { - alias: 'p', - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'App-side ZeroMQ PUB endpoint to connect to.', - array: true, - demand: true - }) - .option('heartbeat-timeout', { - alias: 't', - describe: 'Consider devices with heartbeat older than the timeout ' + - 'value dead. Given in milliseconds.', - type: 'number', - default: 30000 - }) - .option('name', { - describe: 'An easily identifiable name for log output.', - type: 'string', - default: os.hostname() - }) - .option('time-to-device-cleanup', { - describe: 'Time in minutes after which connected devices should be deleted. 0 - do not delete', - type: 'number', - default: 0 - }) - .option('device-cleanup-interval', { - describe: 'Interval for checking devices for cleanup in minutes', - type: 'number', - default: 2 - }) - .epilog('Each option can be be overwritten with an environment variable ' + - 'by converting the option to uppercase, replacing dashes with ' + - 'underscores and prefixing it with `STF_REAPER_` (e.g. ' + - '`STF_REAPER_CONNECT_PUSH`).') -} -export const handler = function(argv) { - return reaper({ - name: argv.name, - heartbeatTimeout: argv.heartbeatTimeout, - timeToDeviceCleanup: argv.timeToDeviceCleanup, - deviceCleanupInterval: argv.deviceCleanupInterval, - endpoints: { - push: argv.connectPush, - sub: argv.connectSub - } - }) -} diff --git a/lib/cli/storage-plugin-apk/index.js b/lib/cli/storage-plugin-apk/index.js index a06d0bb4f7..cc31527585 100644 --- a/lib/cli/storage-plugin-apk/index.js +++ b/lib/cli/storage-plugin-apk/index.js @@ -4,8 +4,7 @@ export const command = 'storage-plugin-apk' export const describe = 'Start an APK storage plugin unit.' export const builder = function(yargs) { return yargs - .env('STF_STORAGE_PLUGIN_APK') - .strict() + .env('DH') .option('port', { alias: 'p', describe: 'The port to bind to.', diff --git a/lib/cli/storage-plugin-image/index.js b/lib/cli/storage-plugin-image/index.js index ba0c9c971d..f6ae4ddbdf 100644 --- a/lib/cli/storage-plugin-image/index.js +++ b/lib/cli/storage-plugin-image/index.js @@ -4,8 +4,7 @@ export const command = 'storage-plugin-image' export const describe = 'Start an image storage plugin unit.' export const builder = function(yargs) { return yargs - .env('STF_STORAGE_PLUGIN_IMAGE') - .strict() + .env('DH') .option('concurrency', { alias: 'c', describe: 'Maximum number of simultaneous transformations.', diff --git a/lib/cli/storage-s3/index.js b/lib/cli/storage-s3/index.js index f08b7004b0..8554b37484 100644 --- a/lib/cli/storage-s3/index.js +++ b/lib/cli/storage-s3/index.js @@ -3,8 +3,7 @@ export const command = 'storage-s3' export const describe = 'Start an S3 storage unit.' export const builder = function(yargs) { return yargs - .env('STF_STORAGE_S3') - .strict() + .env('DH') .option('bucket', { describe: 'S3 bucket name.', type: 'string', diff --git a/lib/cli/storage-temp/index.js b/lib/cli/storage-temp/index.js index 043a399305..87620e8080 100644 --- a/lib/cli/storage-temp/index.js +++ b/lib/cli/storage-temp/index.js @@ -4,8 +4,7 @@ export const command = 'storage-temp' export const describe = 'Start a temp storage unit.' export const builder = function(yargs) { return yargs - .env('STF_STORAGE_TEMP') - .strict() + .env('DH') .option('max-file-size', { describe: 'Maximum file size to allow for uploads. Note that nginx ' + 'may have a separate limit, meaning you should change both.', @@ -18,16 +17,6 @@ export const builder = function(yargs) { type: 'number', default: process.env.PORT || 7100 }) - .option('connect-push', { - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) .option('save-dir', { describe: 'The location where files are saved to.', type: 'string', @@ -104,10 +93,6 @@ export const handler = function(argv) { port: argv.port, saveDir: argv.saveDir, maxFileSize: argv.maxFileSize, - endpoints: { - push: argv.connectPush, - sub: argv.connectSub - }, publicIp: argv.publicIp, bundletoolPath: argv.bundletoolPath, keystore: { diff --git a/lib/cli/tizen-device/index.js b/lib/cli/tizen-device/index.js index dd47fab326..0c7a623c60 100644 --- a/lib/cli/tizen-device/index.js +++ b/lib/cli/tizen-device/index.js @@ -3,7 +3,6 @@ export const command = 'tizen-device' export const describe = 'Start an tizen device unit.' export const builder = function(yargs) { return yargs - .strict() .option('device-name', { describe: 'Device name.' }) @@ -33,37 +32,9 @@ export const builder = function(yargs) { type: 'number', demand: true }) - .option('connect-push', { + .option('connect-processor', { alias: 'p', - describe: 'ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'ZeroMQ PUB endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-app-dealer', { - describe: 'App-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-dev-dealer', { - describe: 'Device-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-push', { - alias: 'p', - describe: 'ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'ZeroMQ PUB endpoint to connect to.', + describe: 'Processor ZeroMQ ROUTER endpoint to connect to.', array: true, demand: true }) @@ -99,16 +70,6 @@ export const builder = function(yargs) { type: 'string', demand: true }) - .option('connect-app-dealer', { - describe: 'App-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-dev-dealer', { - describe: 'Device-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true - }) .option('device-host', { describe: 'Device host.', type: 'string', @@ -145,10 +106,7 @@ export const handler = function(argv) { publicIp: argv.publicIp, publicPort: argv.publicPort, endpoints: { - sub: argv.connectSub.filter(e => !!e.trim()), - push: argv.connectPush.filter(e => !!e.trim()), - appDealer: argv.connectAppDealer.filter(e => !!e.trim()), - devDealer: argv.connectDevDealer.filter(e => !!e.trim()) + processor: argv.connectProcessor.filter(e => !!e.trim()) }, groupTimeout: argv.groupTimeout * 1000, connectUrlPattern: argv.connectUrlPattern, diff --git a/lib/cli/triproxy/index.js b/lib/cli/triproxy/index.js deleted file mode 100644 index dd67f3906d..0000000000 --- a/lib/cli/triproxy/index.js +++ /dev/null @@ -1,46 +0,0 @@ -import os from 'os' -import triproxy from '../../units/triproxy/index.js' -export const command = 'triproxy [name]' -export const describe = 'Start a triproxy unit.' -export const builder = function(yargs) { - return yargs - .env('STF_TRIPROXY') - .strict() - .option('bind-dealer', { - alias: 'd', - describe: 'The address to bind the ZeroMQ DEALER endpoint to.', - type: 'string', - default: 'tcp://*:7112' - }) - .option('bind-pub', { - alias: 'u', - describe: 'The address to bind the ZeroMQ PUB endpoint to.', - type: 'string', - default: 'tcp://*:7111' - }) - .option('bind-pull', { - alias: 'p', - describe: 'The address to bind the ZeroMQ PULL endpoint to.', - type: 'string', - default: 'tcp://*:7113' - }) - .option('name', { - describe: 'An easily identifiable name for log output.', - type: 'string', - default: os.hostname() - }) - .epilog('Each option can be be overwritten with an environment variable ' + - 'by converting the option to uppercase, replacing dashes with ' + - 'underscores and prefixing it with `STF_TRIPROXY_` (e.g. ' + - '`STF_TRIPROXY_BIND_PUB`).') -} -export const handler = function(argv) { - return triproxy({ - name: argv.name, - endpoints: { - pub: argv.bindPub, - dealer: argv.bindDealer, - pull: argv.bindPull - } - }) -} diff --git a/lib/cli/vnc-device/example b/lib/cli/vnc-device/example index 73a580af04..1b5ec6872a 100644 --- a/lib/cli/vnc-device/example +++ b/lib/cli/vnc-device/example @@ -8,7 +8,4 @@ stf vnc-device \ --public-ip localhost \ --screen-ws-url-pattern "ws://localhost:7409" \ --storage-url http://localhost:7100/ \ - --connect-sub tcp://127.0.0.1:7114 \ - --connect-push tcp://127.0.0.1:7116 \ - --connect-app-dealer tcp://127.0.0.1:7112 \ - --connect-dev-dealer tcp://127.0.0.1:7115 \ + --connect-processor tcp://127.0.0.1:7160 \ diff --git a/lib/cli/vnc-device/index.js b/lib/cli/vnc-device/index.js index e98eaa25bc..4f3ddd0b5f 100644 --- a/lib/cli/vnc-device/index.js +++ b/lib/cli/vnc-device/index.js @@ -6,16 +6,9 @@ export const describe = 'Start an vnc device provider.' export function builder(yargs) { return yargs - .strict() - .option('connect-push', { + .option('connect-processor', { alias: 'p', - describe: 'ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 's', - describe: 'ZeroMQ PUB endpoint to connect to.', + describe: 'Processor ZeroMQ ROUTER endpoint to connect to.', array: true, demand: true }) @@ -85,16 +78,6 @@ export function builder(yargs) { type: 'string', demand: true }) - .option('connect-app-dealer', { - describe: 'App-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-dev-dealer', { - describe: 'Device-side ZeroMQ DEALER endpoint to connect to.', - array: true, - demand: true - }) .option('vnc-port', { describe: 'The port where vnc is started.', type: 'number', @@ -141,10 +124,7 @@ export function handler(argv) { provider: argv.provider, publicIp: argv.publicIp, endpoints: { - sub: argv.connectSub, - push: argv.connectPush, - appDealer: argv.connectAppDealer, - devDealer: argv.connectDevDealer + processor: argv.connectProcessor }, groupTimeout: argv.groupTimeout * 1000, // change to ms storageUrl: argv.storageUrl, diff --git a/lib/cli/websocket/index.js b/lib/cli/websocket/index.js index 7b7c97f3ae..48bbe879d7 100644 --- a/lib/cli/websocket/index.js +++ b/lib/cli/websocket/index.js @@ -3,29 +3,10 @@ export const command = 'websocket' export const describe = 'Start a websocket unit.' export const builder = function(yargs) { return yargs - .env('STF_WEBSOCKET') - .strict() - .option('connect-push', { + .env('DH') + .option('connect-proxy', { alias: 'c', - describe: 'App-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-push-dev', { - alias: 'pd', - describe: 'Device-side ZeroMQ PULL endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub', { - alias: 'u', - describe: 'App-side ZeroMQ PUB endpoint to connect to.', - array: true, - demand: true - }) - .option('connect-sub-dev', { - alias: 'sd', - describe: 'Device-side ZeroMQ PUB endpoint to connect to.', + describe: 'Proxy ROUTER endpoint(s) to connect to.', array: true, demand: true }) @@ -68,10 +49,7 @@ export const handler = function(argv) { ssid: argv.ssid, storageUrl: argv.storageUrl, endpoints: { - sub: argv.connectSub, - subdev: argv.connectSubDev, - push: argv.connectPush, - pushdev: argv.connectPushDev + proxy: argv.connectProxy } }) } diff --git a/lib/db/handlers/group/index.js b/lib/db/handlers/group/index.js index 74e7a8b92a..71643db1b8 100644 --- a/lib/db/handlers/group/index.js +++ b/lib/db/handlers/group/index.js @@ -1,11 +1,11 @@ import timeutil from '../../../util/timeutil.js' import apiutil from '../../../util/apiutil.js' +import logger from '../../../util/logger.js' import wireutil from '../../../wire/util.js' import wire from '../../../wire/index.js' import dbapi from '../../api.js' import GroupsScheduler from './scheduler.js' -import {WireRouter} from '../../../wire/router.js' -import {GroupChangeMessage, GroupField, LeaveGroupMessage, UngroupMessage} from '../../../wire/wire.js' +import {GroupChangeMessage, GroupField, UngroupMessage} from '../../../wire/wire.js' class GroupChangeHandler { @@ -13,12 +13,16 @@ class GroupChangeHandler { // @ts-ignore scheduler isPrepared = false - - init(push, pushdev, channelRouter) { - this.push = push - this.pushdev = pushdev - this.channelRouter = channelRouter - this.isPrepared = !!this.push && !!this.pushdev && !!this.channelRouter + log = logger.createLogger('change-handler-groups') + + /** + * @param {import('../../../wire/app-transport.js').AppTransport} transport + * @param {import('../../../wire/transmanager.js').TransactionManager=} txmanager + */ + init(transport, txmanager) { + this.transport = transport + this.txmanager = txmanager + this.isPrepared = !!this.transport && !!this.txmanager } async initScheduler() { @@ -28,18 +32,28 @@ class GroupChangeHandler { } } - sendReleaseDeviceControl = (serial, channel) => { + // Run an Ungroup transaction to release control of the device. + // Device is addressed by (provider.name, serial); transaction resolution confirms release. + sendReleaseDeviceControl = async(serial) => { this.scheduler?.scheduleAllGroupsTasks() - this.push.send([ - channel, - wireutil.pack(UngroupMessage, { + const device = await dbapi.loadDeviceBySerial(serial) + if (!device?.provider?.name) { + this.log.warn('Cannot release control of %s: no provider', serial) + return + } + try { + await this.txmanager.runTransaction(device.provider.name, serial, UngroupMessage, { requirements: wireutil.toDeviceRequirements({ serial: { value: serial, match: 'exact' } - })}) - ]) + }) + }, {timeout: 5000}) + } + catch (err) { + this.log.warn('Release control transaction for %s failed: %s', serial, err?.message) + } } sendGroupChange = (group, subscribers, isChangedDates, isChangedClass, isAddedUser, users, isAddedDevice, devices, action) => { @@ -50,8 +64,7 @@ class GroupChangeHandler { stop: date.stop.toJSON() })) - this.pushdev.send([ - wireutil.global, + this.transport.sendBroadcast( wireutil.pack(GroupChangeMessage, { group: GroupField.create({ id: group.id, @@ -78,20 +91,19 @@ class GroupChangeHandler { devices: devices, timeStamp: timeutil.now('nano') }) - ]) + ) } sendGroupUsersChange = (group, users, devices, isAdded, action) => { this.scheduler?.scheduleAllGroupsTasks() - this.pushdev.send([ - wireutil.global, + this.transport.sendBroadcast( wireutil.envelope(new wire.GroupUserChangeMessage( users, isAdded, group.id , action === 'GroupDeletedLater' , devices )) - ]) + ) } doUpdateDeviceOriginGroup = async(group, serial, signature) => { @@ -99,10 +111,9 @@ class GroupChangeHandler { const result = await dbapi.updateDevicesOriginGroup(serial, group) if (result) { - this.push.send([ - wireutil.global, + this.transport.sendBroadcast( wireutil.envelope(new wire.DeviceOriginGroupMessage(signature)) - ]) + ) } return result @@ -144,7 +155,7 @@ class GroupChangeHandler { } return Promise.all(users?.map(async(email) => { const devices = await Promise.all(group.devices?.map( - serial => new Promise(async(resolve) => { + async(serial) => { const device = await dbapi.loadDeviceBySerial(serial) if (!device || device.group.id !== group.id) { return null @@ -153,25 +164,10 @@ class GroupChangeHandler { return serial } - const listener = new WireRouter() - .on(LeaveGroupMessage, (channel, message) => { - if (message.serial === serial && - message.owner.email === email) { - clearTimeout(responseTimer) - this.channelRouter.removeListener(wireutil.global, listener) - resolve(serial) - } - }) - .handler() - - const responseTimer = setTimeout(() => { - this.channelRouter.removeListener(wireutil.global, listener) - resolve(serial) - }, 5000) - - this.channelRouter.on(wireutil.global, listener) - this.sendReleaseDeviceControl(serial, device.channel) - }) + // The user is losing control of this device: run the Ungroup transaction. + await this.sendReleaseDeviceControl(serial) + return serial + } )) this.sendGroupUsersChange( @@ -213,10 +209,8 @@ class GroupChangeHandler { } } -// Temporary solution needed to avoid situations -// where a unit may not initialize the change handler, -// but use the db module. In this case, any methods of this handler -// do nothing and will not cause an error. +// Proxy that no-ops all methods if the handler was not initialized, +// preventing errors when units use the db module without transport. /** @type {GroupChangeHandler} */ export default new Proxy(new GroupChangeHandler(), { diff --git a/lib/db/handlers/user/index.js b/lib/db/handlers/user/index.js index 132d9a35a0..35f8444090 100644 --- a/lib/db/handlers/user/index.js +++ b/lib/db/handlers/user/index.js @@ -9,9 +9,10 @@ class UserChangeHandler { isPrepared = false log = logger.createLogger('change-handler-users') - init(push, pushdev, channelRouter) { - this.pushdev = pushdev - this.isPrepared = !!this.pushdev + /** @param {import('../../../wire/app-transport.js').AppTransport} transport */ + init(transport) { + this.transport = transport + this.isPrepared = !!this.transport } sendUserChange = (user, isAddedGroup, groups, action, targets) => { @@ -32,8 +33,9 @@ class UserChangeHandler { return wireUser } const userField = wireUserField() - this.pushdev.send([ - wireutil.global, + // UserChange is a broadcast-to-app: the proxy fans it out to all + // registered broadcast receivers (websocket) for client-side filtering. + this.transport.sendBroadcast( wireutil.envelope(new wire.UserChangeMessage( userField , isAddedGroup @@ -42,7 +44,7 @@ class UserChangeHandler { , targets , timeutil.now('nano') )) - ]) + ) } updateUserHandler = (oldUser, newUser) => { @@ -85,10 +87,8 @@ class UserChangeHandler { } -// Temporary solution needed to avoid situations -// where a unit may not initialize the change handler, -// but use the db module. In this case, any methods of this handler -// do nothing and will not cause an error. +// Proxy that no-ops all methods if the handler was not initialized, +// preventing errors when units use the db module without transport. /** @type {UserChangeHandler} */ export default new Proxy(new UserChangeHandler(), { diff --git a/lib/db/index.ts b/lib/db/index.ts index 8e3ab3b1bf..be742f2e1b 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,14 +1,11 @@ import mongo from 'mongodb' import _setup from './setup.js' import srv from '../util/srv.js' -import EventEmitter from 'events' import GroupChangeHandler from './handlers/group/index.js' import UserChangeHandler from './handlers/user/index.js' -import * as zmqutil from '../util/zmqutil.js' -import lifecycle from '../util/lifecycle.js' import logger from '../util/logger.js' -import wireutil from '../wire/util.js' -import {type SocketWrapper} from '../util/zmqutil.js' +import type {AppTransport} from '../wire/app-transport.js' +import type {TransactionManager} from '../wire/transmanager.js' const log = logger.createLogger('db') @@ -23,9 +20,8 @@ const options = { const handlers: { init: ( - push?: SocketWrapper, - pushdev?: SocketWrapper, - channelRouter?: EventEmitter + transport: AppTransport, + txmanager?: TransactionManager ) => Promise | void; isPrepared: boolean; }[] = [GroupChangeHandler, UserChangeHandler] @@ -35,36 +31,40 @@ export default class DbClient { static async connect(): Promise; static async connect(opts: { - push?: SocketWrapper; - pushdev?: SocketWrapper; - channelRouter?: EventEmitter; + transport?: AppTransport; + txmanager?: TransactionManager; groupsScheduler?: boolean; }): Promise; /** - * Create a connection and initialize the change handlers for entities. \ - * Called once. \ - * \ - * Note: No longer needed to get collection. \ - * Use `DbClient.collection('name')` \ + * Create a connection and initialize the change handlers for entities. + * Called once. + * + * The change handlers publish broadcast-to-app events + * (GroupChange UserChange) over the app-side ROUTER/DEALER transport, and the group + * handler runs device transactions (Ungroup) via the TransactionManager. A + * unit that owns those handlers passes its `transport` (and `txmanager`); + * units that only read the DB call `connect()` with no transport, leaving the + * handlers as no-ops. + * + * Note: No longer needed to get collection. + * Use `DbClient.collection('name')` * Or `DbClient.groups` */ static async connect( opts: { - push?: SocketWrapper; - pushdev?: SocketWrapper; - channelRouter?: EventEmitter; + transport?: AppTransport; + txmanager?: TransactionManager; groupsScheduler?: boolean; } = {} ): Promise { // Init entities change handlers - if (opts.push && opts.pushdev && opts.channelRouter) { + if (opts.transport) { for (const changeHandler of handlers) { if (!changeHandler.isPrepared) { await changeHandler.init( - opts.push, - opts.pushdev, - opts.channelRouter + opts.transport, + opts.txmanager ) } } @@ -110,173 +110,6 @@ export default class DbClient { return DbClient.collection('teams') } - static async createZMQSockets( - { - sub, - subdev, - push, - pushdev, - channelRouter, - }: { - sub?: SocketWrapper | string[]; - subdev?: SocketWrapper | string[]; - push: SocketWrapper | string[]; - pushdev: SocketWrapper | string[]; - channelRouter?: EventEmitter; - }, - _log: ReturnType | undefined = log - ) { - let finalSub: SocketWrapper | undefined - let finalSubdev: SocketWrapper | undefined - - if (sub && Array.isArray(sub)) { - const _sub = zmqutil.socket('sub') - await Promise.all( - sub.map(async(endpoint) => { - try { - srv.attempt( - await srv.resolve(endpoint), - async(record) => { - _log.info( - 'Receiving input from "%s"', - record.url - ) - _sub.connect(record.url) - } - ) - } - catch (err) { - _log.fatal('Unable to connect to sub endpoint: %s', err) - lifecycle.fatal() - } - }) - ) - finalSub = _sub - } - else if (sub) { - finalSub = sub as SocketWrapper - } - - if (subdev && Array.isArray(subdev)) { - const _subdev = zmqutil.socket('sub') - await Promise.all( - subdev.map(async(endpoint) => { - try { - srv.attempt( - await srv.resolve(endpoint), - async(record) => { - _log.info( - 'Receiving input from "%s"', - record.url - ) - _subdev.connect(record.url) - } - ) - } - catch (err) { - _log.fatal('Unable to connect to subdev endpoint: %s', err) - lifecycle.fatal() - } - }) - ) - finalSubdev = _subdev - } - else if (subdev) { - finalSubdev = subdev as SocketWrapper - } - - if (Array.isArray(push)) { - const _push = zmqutil.socket('push') - await Promise.all( - push.map(async(endpoint) => { - try { - srv.attempt( - await srv.resolve(endpoint), - async(record) => { - _log.info('Sending output to "%s"', record.url) - _push.connect(record.url) - } - ) - } - catch (err) { - _log.fatal('Unable to connect to push endpoint: %s', err) - lifecycle.fatal() - } - }) - ) - push = _push - } - - if (Array.isArray(pushdev)) { - const _pushdev = zmqutil.socket('push') - await Promise.all( - pushdev.map(async(endpoint) => { - try { - srv.attempt( - await srv.resolve(endpoint), - async(record) => { - _log.info('Sending output to "%s"', record.url) - _pushdev.connect(record.url) - } - ) - } - catch (err) { - _log.fatal( - 'Unable to connect to pushdev endpoint: %s', - err - ) - lifecycle.fatal() - } - }) - ) - pushdev = _pushdev - } - - if (!channelRouter) { - channelRouter = new EventEmitter() - - if (finalSub || finalSubdev) { - ;[wireutil.global].forEach((channel) => { - _log.info('Subscribing to permanent channel "%s"', channel) - if (finalSub) { - finalSub.subscribe(channel) - } - if (finalSubdev) { - finalSubdev.subscribe(channel) - } - }) - - if (finalSub) { - finalSub.on('message', (channel, data) => { - channelRouter?.emit(channel.toString(), channel, data) - }) - } - - if (finalSubdev) { - finalSubdev.on('message', (channel, data) => { - channelRouter?.emit(channel.toString(), channel, data) - }) - } - } - } - - const result: { - sub?: SocketWrapper; - subdev?: SocketWrapper; - push: SocketWrapper; - pushdev: SocketWrapper; - channelRouter: EventEmitter; - } = { - push, - pushdev, - channelRouter, - ... !!finalSub && {sub: finalSub}, - ... !!finalSubdev && {subdev: finalSubdev} - } - - return result - } - // Verifies that we can form a connection. Useful if it's necessary to make // sure that a handler doesn't run at all if the database is on a break. In // normal operation connections are formed lazily. In particular, this was diff --git a/lib/db/models/all/model.js b/lib/db/models/all/model.js index ee5eb4d042..4c083681c1 100644 --- a/lib/db/models/all/model.js +++ b/lib/db/models/all/model.js @@ -395,7 +395,7 @@ export const saveDeviceLog = function(serial, entry) { export const saveIosDeviceInitialState = function(publicIp, message) { const data = { - present: true, + present: false, presenceChangedAt: getNow(), provider: message.provider, owner: null, @@ -460,7 +460,7 @@ export const saveIosDeviceInitialState = function(publicIp, message) { // dbapi.saveDeviceInitialState = function(serial, device) { export const saveDeviceInitialState = function(serial, device) { let data = { - present: true, + present: false, presenceChangedAt: getNow(), provider: device.provider, owner: null, @@ -605,29 +605,39 @@ export const unsetDeviceOwner = function(serial) { } // dbapi.setDevicePresent = function(serial) { -export const setDevicePresent = function(serial) { +export const setDevicePresent = function(serial, presenceChangedAt) { + const timestamp = presenceChangedAt ? new Date(presenceChangedAt) : getNow() + const filter = presenceChangedAt ? + {serial, $or: [{presenceChangedAt: {$lte: timestamp}}, {presenceChangedAt: {$exists: false}}]} : + {serial} + return db.devices.updateOne( - {serial: serial}, + filter, { $set: { present: true, - presenceChangedAt: getNow() + presenceChangedAt: timestamp } } ) } // dbapi.setDeviceAbsent = function(serial) { -export const setDeviceAbsent = function(serial) { +export const setDeviceAbsent = function(serial, presenceChangedAt) { + const timestamp = presenceChangedAt ? new Date(presenceChangedAt) : getNow() + const filter = presenceChangedAt ? + {serial, $or: [{presenceChangedAt: {$lte: timestamp}}, {presenceChangedAt: {$exists: false}}]} : + {serial} + return db.devices.updateOne( - {serial: serial}, + filter, { $set: { owner: null, usage: null, logs_enabled: false, present: false, - presenceChangedAt: getNow() + presenceChangedAt: timestamp } } ) diff --git a/lib/db/models/device/model.js b/lib/db/models/device/model.js index b00c715014..7259f463f4 100644 --- a/lib/db/models/device/model.js +++ b/lib/db/models/device/model.js @@ -32,6 +32,15 @@ export const loadPresentDevices = function() { return db.devices.find({present: true}).toArray() } +export const loadDevicesPresentBefore = function(date) { + return db.devices.find( + { + present: true, + presenceChangedAt: {$lt: date} + } + ).project({serial: 1, 'provider.name': 1}).toArray() +} + export const loadDeviceBySerial = function(serial) { return db.devices.findOne({serial}) } @@ -115,6 +124,11 @@ export const generateIndexes = function() { db.devices.createIndex({serial: -1}).then((result) => { log.info('Created indexes with result - ' + result) }) + // Backs the presence-by-age queries (getDeadDevice, loadDevicesPresentBefore), + // which would otherwise be collection scans. + db.devices.createIndex({present: 1, presenceChangedAt: 1}).then((result) => { + log.info('Created presence indexes with result - ' + result) + }) } /* diff --git a/lib/units/api/controllers/autotests.js b/lib/units/api/controllers/autotests.js index 93be14cafd..1c6230d41e 100644 --- a/lib/units/api/controllers/autotests.js +++ b/lib/units/api/controllers/autotests.js @@ -1,15 +1,11 @@ import * as apiutil from '../../../util/apiutil.js' import groups from './groups.js' import dbapi from '../../../db/api.js' -import wireutil from '../../../wire/util.js' -import {WireRouter} from '../../../wire/router.js' -import wire from '../../../wire/index.js' -import {v4 as uuidv4} from 'uuid' import logger from '../../../util/logger.js' import * as Sentry from '@sentry/node' import _ from 'lodash' -import useDevice, {UseDeviceError} from '../helpers/useDevice.js' -import {InstallResultMessage} from '../../../wire/wire.js' +import {useDevice, UseDeviceError} from '../helpers/useDevice.js' +import {InstallMessage} from '../../../wire/wire.js' const log = logger.createLogger('api:controllers:autotests') @@ -133,7 +129,7 @@ function installOnDevice(req, res) { package: 'app_from_api', application: {launcherActivities: []} } - return dbapi.loadDeviceBySerial(serial).then(device => { + return dbapi.loadDeviceBySerial(serial).then(async device => { if (device === null) { res.status(404).json({ success: false, @@ -141,45 +137,35 @@ function installOnDevice(req, res) { }) return } - let responseChannel = 'txn_' + uuidv4() - req.options.sub.subscribe(responseChannel) - // Timer will be called if no InstallResultMessage is received till 5 seconds - let timer = setTimeout(function() { - req.options.channelRouter.removeListener(responseChannel, messageListener) - req.options.sub.unsubscribe(responseChannel) - log.info('Installation result: Device is not responding') - return apiutil.respond(res, 504, 'Device is not responding') - }, apiutil.INSTALL_APK_WAIT) - let messageListener = new WireRouter() - .on(InstallResultMessage, function(channel, message) { - if (message.serial === serial) { - clearTimeout(timer) - req.options.sub.unsubscribe(responseChannel) - req.options.channelRouter.removeListener(responseChannel, messageListener) - log.info('Installation result:' + message.result) - if (message.result === 'Installed successfully') { - return res.json({ - success: true, - description: message.result - }) - } - else { - return res.status(400).json({ - success: false, - description: message.result - }) - } - } + log.info('Sending InstallMessage to %s/%s', device.provider.name, device.serial) + try { + const result = await req.options.txmanager.runTransaction( + device.provider.name, + device.serial, + InstallMessage, + { + href: apkUrl, + launch: false, // <- doesn't work + isApi: true, + manifest: JSON.stringify(manifest), + installFlags: installFlags || [], + jwt: req.internalJwt + }, + {timeout: apiutil.INSTALL_APK_WAIT} + ) + log.info('Installation result: ' + result.data) + return res.json({ + success: true, + description: result.data }) - .handler() - req.options.channelRouter.on(responseChannel, messageListener) - let isApi = true - log.info('Sending InstallMessage on channel ', device.channel, ' with response in ', responseChannel) - req.options.push.send([ - device.channel, - wireutil.transaction(responseChannel, new wire.InstallMessage(apkUrl, false, // <- doesn't work - isApi, JSON.stringify(manifest), installFlags, req.internalJwt)) - ]) + } + catch (/** @type {any} */ err) { + log.info('Installation failed: ' + (err?.data ?? err?.message ?? err)) + return res.status(400).json({ + success: false, + description: err?.data ?? 'Installation failed' + }) + } }) } @@ -190,11 +176,10 @@ async function useAndConnectDevice(req, res) { const remoteConnectUrl = await useDevice({ user: req.user, device, - channelRouter: req.options.channelRouter, - push: req.options.push, - sub: req.options.sub, usage: 'automation', - log + log, + runTransaction: (mt, m, opts) => + req.options.txmanager.runTransaction(device.provider.name, device.serial, mt, m, opts), }) return res.json({ diff --git a/lib/units/api/controllers/devices.js b/lib/units/api/controllers/devices.js index 58a037c5b0..33de97bdab 100644 --- a/lib/units/api/controllers/devices.js +++ b/lib/units/api/controllers/devices.js @@ -12,7 +12,7 @@ import {WireRouter} from '../../../wire/router.js' import * as fake from '../../../util/fakedevice.js' import deviceutil from '../../../util/deviceutil.js' import * as jwtutil from '../../../util/jwtutil.js' -import useDevice, {UseDeviceError} from '../helpers/useDevice.js' +import {useDevice, UseDeviceError} from '../helpers/useDevice.js' import * as Sentry from '@sentry/node' import {accessTokenAuth} from '../helpers/securityHandlers.js' import {DeviceOriginGroupMessage} from '../../../wire/wire.js' @@ -645,10 +645,9 @@ async function useDeviceByUser(req, res) { const device = await dbapi.loadDevice(user?.groups?.subscribed, req.params.serial) const remoteConnectUrl = await useDevice({ user, device, log, - channelRouter: req.options.channelRouter, - push: req.options.push, - sub: req.options.sub, - usage: null + usage: null, + runTransaction: (mt, m, opts) => + req.options.txmanager.runTransaction(device.provider.name, device.serial, mt, m, opts), }) return res.json({ diff --git a/lib/units/api/controllers/user.js b/lib/units/api/controllers/user.js index 390d7c7c5d..728aa13d95 100644 --- a/lib/units/api/controllers/user.js +++ b/lib/units/api/controllers/user.js @@ -1,43 +1,17 @@ -import util from 'util' import _ from 'lodash' -import {v4 as uuidv4} from 'uuid' import {Adb} from '@u4/adbkit' import dbapi from '../../../db/api.js' import logger from '../../../util/logger.js' import datautil from '../../../util/datautil.js' import deviceutil from '../../../util/deviceutil.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' -import {WireRouter} from '../../../wire/router.js' import * as apiutil from '../../../util/apiutil.js' -import * as jwtutil from '../../../util/jwtutil.js' import * as lockutil from '../../../util/lockutil.js' import * as Sentry from '@sentry/node' import generateToken from '../helpers/generateToken.js' -import {ConnectStartedMessage, ConnectStoppedMessage, JoinGroupMessage, LeaveGroupMessage, UngroupMessage} from '../../../wire/wire.js' -import {runTransaction} from '../../../wire/transmanager.js' +import {AdbKeysUpdatedMessage, ConnectStartMessage, ConnectStopMessage, GroupMessage, OwnerMessage, UpdateAccessTokenMessage, UngroupMessage} from '../../../wire/wire.js' let log = logger.createLogger('api:controllers:user') - -/** - * Safe version of setTimeout - * @param {() => any} callback the callback to run - * @param {number} timeout the timeout to wait - * @returns {number} setTimeout id - */ -function setTimeoutS(callback, timeout) { - // @ts-ignore - return setTimeout(function() { - try { - return callback() - } - catch (/** @type {any} */ e) { - log.error('Error when executing timeout: ', e?.stack || e) - return null - } - }, timeout) -} - function getUser(req, res) { // delete req.user.groups.lock res.json({ @@ -117,7 +91,7 @@ function addUserDevice(req, res) { null : req.query.timeout || null const lock = {} return lockutil.lockGenericDevice(req, res, lock, dbapi.lockDeviceByCurrent) - .then(function(lockingSuccessed) { + .then(async function(lockingSuccessed) { if (lockingSuccessed) { const device = lock.device datautil.normalize(device, req.user) @@ -127,42 +101,35 @@ function addUserDevice(req, res) { description: 'Device is being used or not available' }) } - // Timer will be called if no JoinGroupMessage is received till 5 seconds - let responseTimer = setTimeoutS(function() { - req.options.channelRouter.removeListener(wireutil.global, messageListener) - return apiutil.respond(res, 504, 'Device is not responding') - }, apiutil.GRPC_WAIT_TIMEOUT) - let messageListener = new WireRouter() - .on(JoinGroupMessage, function(channel, message) { - log.info(device.serial + ' added to user group ' + req.user) - if (message.serial === serial && message.owner.email === req.user.email) { - clearTimeout(responseTimer) - req.options.channelRouter.removeListener(wireutil.global, messageListener) - return res.json({ - success: true, - description: 'Device successfully added' - }) + const usage = 'automation' + try { + await req.options.txmanager.runTransaction( + device.provider.name, + device.serial, + GroupMessage, + { + owner: OwnerMessage.create({ + email: req.user.email, + name: req.user.name, + group: req.user.group + }), + requirements: wireutil.toDeviceRequirements({ + serial: {value: serial, match: 'exact'} + }), + usage, + timeout: timeout || undefined, + keys: req.user.adbKeys.map(key => key.fingerprint) } + ) + log.info(device.serial + ' added to user group ' + req.user.email) + return res.json({ + success: true, + description: 'Device successfully added' }) - .handler() - req.options.channelRouter.on(wireutil.global, messageListener) - let usage = 'automation' - req.options.push.send([ - device.channel, - wireutil.envelope(new wire.GroupMessage( - new wire.OwnerMessage(req.user.email, req.user.name, req.user.group), - timeout, - wireutil.toDeviceRequirements({ - serial: { - value: serial, - match: 'exact' - } - }), - usage, - req.user.adbKeys - .map(key => key.fingerprint) - )) - ]) + } + catch (err) { + return apiutil.respond(res, 504, 'Device is not responding') + } } return false }) @@ -216,18 +183,16 @@ function deleteUserDeviceBySerial(req, res) { } } - await runTransaction(device.channel, UngroupMessage, { - requirements: wireutil.toDeviceRequirements({ - serial: { - value: serial, - match: 'exact' - } - }) - }, { - sub: req.options.sub, - push: req.options.push, - channelRouter: req.options.channelRouter - }) + await req.options.txmanager.runTransaction( + device.provider.name, + device.serial, + UngroupMessage, + { + requirements: wireutil.toDeviceRequirements({ + serial: {value: serial, match: 'exact'} + }) + } + ) }) .catch(function(err) { let errSerial @@ -250,7 +215,7 @@ function deleteUserDeviceBySerial(req, res) { function remoteConnectUserDeviceBySerial(req, res) { let serial = req.params.serial return dbapi.loadDevice(req.user.groups.subscribed, serial) - .then(function(device) { + .then(async function(device) { if (!device) { return res.status(404).json({ success: false, @@ -264,33 +229,22 @@ function remoteConnectUserDeviceBySerial(req, res) { description: 'Device is not owned by you or is not available' }) } - let responseChannel = 'txn_' + uuidv4() - req.options.sub.subscribe(responseChannel) - // Timer will be called if no JoinGroupMessage is received till 5 seconds - let timer = setTimeoutS(function() { - req.options.channelRouter.removeListener(responseChannel, messageListener) - req.options.sub.unsubscribe(responseChannel) - return apiutil.respond(res, 504, 'Device is not responding') - }, apiutil.GRPC_WAIT_TIMEOUT) - let messageListener = new WireRouter() - .on(ConnectStartedMessage, function(channel, message) { - if (message.serial === serial) { - clearTimeout(timer) - req.options.sub.unsubscribe(responseChannel) - req.options.channelRouter.removeListener(responseChannel, messageListener) - return res.json({ - success: true, - description: 'Remote connection is enabled', - remoteConnectUrl: message.url - }) - } + try { + const result = await req.options.txmanager.runTransaction( + device.provider.name, + device.serial, + ConnectStartMessage, + {} + ) + return res.json({ + success: true, + description: 'Remote connection is enabled', + remoteConnectUrl: result.data }) - .handler() - req.options.channelRouter.on(responseChannel, messageListener) - req.options.push.send([ - device.channel, - wireutil.transaction(responseChannel, new wire.ConnectStartMessage()) - ]) + } + catch (err) { + return apiutil.respond(res, 504, 'Device is not responding') + } }) .catch(function(err) { log.error('Failed to load device "%s": ', req.params.serial, err.stack) @@ -308,7 +262,7 @@ function remoteDisconnectUserDeviceBySerial(req, res) { serial = req.params.serial } return dbapi.loadDevice(req.user.groups.subscribed, serial) - .then(function(device) { + .then(async function(device) { if (!device) { if (isInternal) { return false @@ -332,42 +286,27 @@ function remoteDisconnectUserDeviceBySerial(req, res) { }) } } - const responseChannel = 'txn_' + uuidv4() - req.options.sub.subscribe(responseChannel) - // Timer will be called if no JoinGroupMessage is received till 5 seconds - const timer = setTimeoutS(function() { - req.options.channelRouter.removeListener(responseChannel, messageListener) - req.options.sub.unsubscribe(responseChannel) + try { + await req.options.txmanager.runTransaction( + device.provider.name, + device.serial, + ConnectStopMessage, + {} + ) if (isInternal) { - return false - } - else { - return apiutil.respond(res, 504, 'Device is not responding') + return true } - }, apiutil.GRPC_WAIT_TIMEOUT) - const messageListener = new WireRouter() - .on(ConnectStoppedMessage, function(channel, message) { - if (message.serial === serial) { - clearTimeout(timer) - req.options.sub.unsubscribe(responseChannel) - req.options.channelRouter.removeListener(responseChannel, messageListener) - if (isInternal) { - return true - } - else { - return res.json({ - success: true, - description: 'Device remote disconnected successfully' - }) - } - } + return res.json({ + success: true, + description: 'Device remote disconnected successfully' }) - .handler() - req.options.channelRouter.on(responseChannel, messageListener) - req.options.push.send([ - device.channel, - wireutil.transaction(responseChannel, new wire.ConnectStopMessage()) - ]) + } + catch (err) { + if (isInternal) { + return false + } + return apiutil.respond(res, 504, 'Device is not responding') + } }) .catch(function(err) { let errSerial @@ -441,11 +380,8 @@ async function addAdbPublicKey(req, res) { } } - // Push update after success - req.options.push.send([ - req.user.group, - wireutil.envelope(new wire.AdbKeysUpdatedMessage()) - ]) + // Broadcast the update; the websocket unit fans it to interested clients. + req.options.transport.sendBroadcast(wireutil.pack(AdbKeysUpdatedMessage, {})) } catch (err) { @@ -535,10 +471,7 @@ function createAccessToken(req, res) { jwt: token.jwt }) .then(function(tokenId) { - req.options.pushdev.send([ - req.user.group, - wireutil.envelope(new wire.UpdateAccessTokenMessage()) - ]) + req.options.transport.sendBroadcast(wireutil.pack(UpdateAccessTokenMessage, {})) apiutil.respond(res, 201, 'Created (access token)', {token: apiutil.publishAccessToken(token)}) }) .catch(function(err) { @@ -553,10 +486,7 @@ function deleteAccessTokens(req, res) { apiutil.respond(res, 200, 'Unchanged (access tokens)') } else { - req.options.pushdev.send([ - req.user.group, - wireutil.envelope(new wire.UpdateAccessTokenMessage()) - ]) + req.options.transport.sendBroadcast(wireutil.pack(UpdateAccessTokenMessage, {})) apiutil.respond(res, 200, 'Deleted (access tokens)') } }) @@ -578,10 +508,7 @@ function deleteAccessToken(req, res) { apiutil.respond(res, 404, 'Not Found (access token)') } else { - req.options.pushdev.send([ - req.user.group, - wireutil.envelope(new wire.UpdateAccessTokenMessage()) - ]) + req.options.transport.sendBroadcast(wireutil.pack(UpdateAccessTokenMessage, {})) apiutil.respond(res, 200, 'Deleted (access token)') } }) diff --git a/lib/units/api/controllers/users.js b/lib/units/api/controllers/users.js index cbb3cecbc6..4fddd566ec 100644 --- a/lib/units/api/controllers/users.js +++ b/lib/units/api/controllers/users.js @@ -2,8 +2,8 @@ import _ from 'lodash' import * as apiutil from '../../../util/apiutil.js' import * as lockutil from '../../../util/lockutil.js' import Promise from 'bluebird' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' +import {DeleteUserMessage} from '../../../wire/wire.js' import userapi from './user.js' import * as service from '../../../util/serviceuser.js' import {MongoServerError} from 'mongodb' @@ -59,10 +59,9 @@ async function removeUser(email, req, res) { function deleteUserInDatabase(channel) { return AllModel.removeUserAccessTokens(email).then(function() { return UserModel.deleteUser(email).then(function() { - req.options.pushdev.send([ - channel, - wireutil.envelope(new wire.DeleteUserMessage(email)) - ]) + // Broadcast the deletion; the websocket unit disconnects the + // affected user's sockets (it filters by email client-side). + req.options.transport.sendBroadcast(wireutil.pack(DeleteUserMessage, {email})) return 'deleted' }) }) diff --git a/lib/units/api/helpers/useDevice.js b/lib/units/api/helpers/useDevice.js deleted file mode 100644 index 01b3ffba78..0000000000 --- a/lib/units/api/helpers/useDevice.js +++ /dev/null @@ -1,127 +0,0 @@ -import {EventEmitter} from 'events' -import {SocketWrapper} from '../../../util/zmqutil.js' -import datautil from '../../../util/datautil.js' -import deviceutil from '../../../util/deviceutil.js' -import wireutil from '../../../wire/util.js' -import * as apiutil from '../../../util/apiutil.js' -import {WireRouter} from '../../../wire/router.js' -import wire from '../../../wire/index.js' -import {v4 as uuidv4} from 'uuid' -import {Log} from '../../../util/logger.js' -import {runTransaction} from '../../../wire/transmanager.js' -import {ConnectStartedMessage, GroupMessage, JoinGroupMessage, OwnerMessage, UngroupMessage} from '../../../wire/wire.js' - -export const UseDeviceError = Object.freeze({ - NOT_FOUND: 0, - ALREADY_IN_USE: 1, - FAILED_JOIN: 2, - FAILED_CONNECT: 3 -}) - -/** - * @param {Object} params - * @param {any} params.user - * @param {any} params.device - * @param {EventEmitter} params.channelRouter - * @param {SocketWrapper} params.push - * @param {SocketWrapper} params.sub - * @param {string|null=} params.usage - * @param {Log=} params.log - * @returns {Promise} - */ -const useDevice = ({user, device, channelRouter, push, sub, usage = null, log}) => new Promise(async(resolve, reject) => { - if (!device) { - return reject(UseDeviceError.NOT_FOUND) - } - - const timeout = (new Date(device.group.lifeTime.stop)).getTime() - Date.now() - log?.info(`Device ${device.serial} in group ${device.group} use with timeout ${timeout}`) - - datautil.normalize(device, user) - if (!deviceutil.isAddable(device)) { - return reject(UseDeviceError.ALREADY_IN_USE) - } - - const deviceRequirements = wireutil.toDeviceRequirements({ - serial: { - value: device.serial, - match: 'exact' - } - }) - - try { - await runTransaction(device.channel, UngroupMessage, { - requirements: deviceRequirements - }, {sub, push, channelRouter}) - } - catch (/** @type {any} */e) { - log?.info('Transaction failed: %s', e?.message) - } - - const responseTimeout = setTimeout(function() { - channelRouter.removeListener(wireutil.global, useDeviceMessageListener) - return reject(UseDeviceError.FAILED_JOIN) - }, apiutil.GRPC_WAIT_TIMEOUT) - - const useDeviceMessageListener = new WireRouter() - .on(JoinGroupMessage, function(channel, message) { - log?.info(device.serial + ' added to user group ' + user) - - if (message.serial === device.serial && message.owner.email === user.email) { - clearTimeout(responseTimeout) - channelRouter.removeListener(wireutil.global, useDeviceMessageListener) - - const responseChannel = 'txn_' + uuidv4() - sub.subscribe(responseChannel) - - const connectTimeout = setTimeout(function() { - channelRouter.removeListener(responseChannel, messageListener) - sub.unsubscribe(responseChannel) - - reject(UseDeviceError.FAILED_CONNECT) - }, apiutil.GRPC_WAIT_TIMEOUT) - - const messageListener = new WireRouter() - .on(ConnectStartedMessage, function(channel, message) { - if (message.serial === device.serial) { - clearTimeout(connectTimeout) - sub.unsubscribe(responseChannel) - channelRouter.removeListener(responseChannel, messageListener) - - resolve(message.url) - } - }) - .handler() - - channelRouter.on(responseChannel, messageListener) - push.send([ - device.channel, - wireutil.transaction(responseChannel, new wire.ConnectStartMessage()) - ]) - } - }) - .handler() - - channelRouter.on(wireutil.global, useDeviceMessageListener) - - try { - await runTransaction(device.channel, GroupMessage, { - owner: OwnerMessage.create({ - email: user.email, - name: user.name, - group: user.group - }), - requirements: deviceRequirements, - usage: usage || undefined, - keys: user.adbKeys?.map((/** @type {{ fingerprint: string }} */ k) => k.fingerprint) || [] - }, {sub, push, channelRouter}) - } - catch (/** @type {any} */e) { - log?.info('Transaction failed: %s', e?.message) - clearTimeout(responseTimeout) - channelRouter.removeListener(wireutil.global, useDeviceMessageListener) - return reject(UseDeviceError.FAILED_JOIN) - } -}) - -export default useDevice diff --git a/lib/units/api/helpers/useDevice.ts b/lib/units/api/helpers/useDevice.ts new file mode 100644 index 0000000000..8cf720e890 --- /dev/null +++ b/lib/units/api/helpers/useDevice.ts @@ -0,0 +1,90 @@ +// +// Pure device-acquisition logic, decoupled from express/dbapi/ZMQ. +// +// The take-control flow is two transactions against a single device, addressed +// by (provider.name, serial) via the app-side TransactionManager: +// 1. GroupMessage — join the device into the requesting user's group. The +// device replies okay() once it has joined, so the transaction resolving +// IS the join confirmation (no separate JoinGroupMessage broadcast needed). +// 2. ConnectStartMessage — start the remote connection. The device replies +// okay(url), so the connect url arrives in the reply's `data` field. +// +// `runTransaction` is injected so this can be unit-tested without any transport; +// the caller binds it to its TransactionManager and the device's provider/serial. +// +import type {MessageType} from '@protobuf-ts/runtime' +import datautil from '../../../util/datautil.js' +import deviceutil from '../../../util/deviceutil.js' +import wireutil from '../../../wire/util.js' +import type {TransactionResult} from '../../../wire/transmanager.js' +import {GroupMessage, ConnectStartMessage, OwnerMessage} from '../../../wire/wire.js' + +export const UseDeviceError = Object.freeze({ + NOT_FOUND: 0, + ALREADY_IN_USE: 1, + FAILED_JOIN: 2, + FAILED_CONNECT: 3, +}) + +export type RunTransaction = ( + messageType: MessageType, + message: T, + opts?: {timeout?: number} +) => Promise + +export interface UseDeviceParams { + user: any + device: any + usage?: string | null + timeout?: number | null + runTransaction: RunTransaction + log?: {info: (...args: any[]) => void} +} + +// Resolves with the device's remote-connect url, or rejects with a +// UseDeviceError code. +export async function useDevice( + {user, device, usage = null, timeout = null, runTransaction, log}: UseDeviceParams +): Promise { + if (!device) { + return Promise.reject(UseDeviceError.NOT_FOUND) + } + + datautil.normalize(device, user) + if (!deviceutil.isAddable(device)) { + return Promise.reject(UseDeviceError.ALREADY_IN_USE) + } + + const requirements = wireutil.toDeviceRequirements({ + serial: {value: device.serial, match: 'exact'}, + }) + + try { + await runTransaction(GroupMessage, { + owner: OwnerMessage.create({ + email: user.email, + name: user.name, + group: user.group, + }), + requirements, + usage: usage || undefined, + timeout: timeout || undefined, + keys: user.adbKeys?.map((k: {fingerprint: string}) => k.fingerprint) || [], + }) + } + catch (err: any) { + log?.info('Group transaction failed: %s', err?.message ?? err) + return Promise.reject(UseDeviceError.FAILED_JOIN) + } + + log?.info('%s added to user group %s', device.serial, user.email) + + try { + const result = await runTransaction(ConnectStartMessage, {}) + return result.data ?? '' + } + catch (err: any) { + log?.info('Connect transaction failed: %s', err?.message ?? err) + return Promise.reject(UseDeviceError.FAILED_CONNECT) + } +} diff --git a/lib/units/api/index.js b/lib/units/api/index.js index e03c7e97d0..7881235477 100644 --- a/lib/units/api/index.js +++ b/lib/units/api/index.js @@ -5,10 +5,14 @@ import _ from 'lodash' import logger from '../../util/logger.js' import {auth} from './auth.js' import lifecycle from '../../util/lifecycle.js' +import srv from '../../util/srv.js' import rateLimitConfig from '../ratelimit/index.js' import bodyParser from 'body-parser' import {accessTokenAuth} from './helpers/securityHandlers.js' import db from '../../db/index.js' +import {DealerSocket} from '../../util/zmqsocket.js' +import {AppTransport} from '../../wire/app-transport.js' +import {TransactionManager} from '../../wire/transmanager.js' const basePath = '/api/v1' const expressOpenapiUse = (app, handler) => (['get', 'post', 'put', 'delete', 'patch', 'options', 'head']).forEach(method => { @@ -51,16 +55,27 @@ export default (async function(options) { log.error('Could not add sentry error handler') } - const { - sub - , subdev - , push - , pushdev - , channelRouter - } = await db.createZMQSockets(options.endpoints, log) - await db.connect({push, pushdev, channelRouter, groupsScheduler: true}) + const dealer = new DealerSocket({probeRouter: true}) + try { + await Promise.all(options.endpoints.proxy.map((endpoint) => + srv.resolve(endpoint).then((records) => + srv.attempt(records, (record) => { + log.info('Sending commands to proxy "%s"', record.url) + dealer.connect(record.url) + return Promise.resolve(true) + }) + ) + )) + } + catch (err) { + log.fatal('Unable to connect to proxy endpoint: %s', (err && err.message) || err) + return lifecycle.fatal() + } - channelRouter.setMaxListeners(100) + const transport = new AppTransport(dealer) + const txmanager = new TransactionManager(transport) + + await db.connect({transport, txmanager, groupsScheduler: true}) app.use(bodyParser.json()) app.use(rateLimitConfig) @@ -73,11 +88,8 @@ export default (async function(options) { expressOpenapiUse(app, (req, res, next) => { // todo: create a proper auth middleware // @ts-ignore req.options = _.merge(options, { - push: push, - sub: sub, - channelRouter: channelRouter, - pushdev: pushdev, - subdev: subdev + transport: transport, + txmanager: txmanager }) if (req.path === '/api/v1/scheme') { next() @@ -116,14 +128,12 @@ export default (async function(options) { }) lifecycle.observe(function() { - [push, sub, pushdev, subdev].forEach(function(sock) { - try { - sock.close() - } - catch (err) { - // No-op - } - }) + try { + transport.close() + } + catch (err) { + // No-op + } }) app.listen(options.port) log.info('Listening on port %d', options.port) diff --git a/lib/units/base-device/plugins/group.ts b/lib/units/base-device/plugins/group.ts index 1ffbd304ed..5fd297cd9b 100755 --- a/lib/units/base-device/plugins/group.ts +++ b/lib/units/base-device/plugins/group.ts @@ -1,20 +1,17 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' import { - AutoGroupMessage, - DeviceStatusChange, GroupMessage, JoinGroupMessage, - LeaveGroupMessage, UngroupMessage + LeaveGroupMessage, + UngroupMessage } from '../../../wire/wire.js' import wireutil from '../../../wire/util.js' import * as grouputil from '../../../util/grouputil.js' import lifecycle from '../../../util/lifecycle.js' -import * as apiutil from '../../../util/apiutil.js' import solo from './solo.js' import router from '../support/router.js' -import push from '../support/push.js' -import sub from '../support/sub.js' +import transport from '../support/transport.js' import channels from '../support/channels.js' import EventEmitter from 'events' @@ -36,10 +33,9 @@ interface GroupEvents { export default syrup.serial() .dependency(solo) .dependency(router) - .dependency(push) - .dependency(sub) + .dependency(transport) .dependency(channels) - .define(async(options, solo, router, push, sub, channels) => { + .define(async(options, solo, router, transport, channels) => { const log = logger.createLogger('base-device:plugins:group') const plugin = new class GroupManager extends EventEmitter { @@ -60,31 +56,19 @@ export default syrup.serial() return this.currentGroup } - join = async(newGroup: GroupState, timeout: number, usage: string, keys: string[]) => { + join = (newGroup: GroupState, timeout: number, usage: string, keys: string[]) => { try { if (!newGroup?.group) { throw new Error(`New group is not valid: ${JSON.stringify(newGroup)}`) } if (!!this.currentGroup?.group) { // if device in group - if (this.currentGroup.group !== newGroup.group) { // and is not same - log.error(`Cannot join group ${JSON.stringify(newGroup)} since this device is in group ${JSON.stringify(this.currentGroup)}`) - throw new grouputil.AlreadyGroupedError() + if (this.currentGroup.group === newGroup.group) { // and is not same + this.keepalive() + return this.currentGroup } - log.info('Update timeout for %s', apiutil.QUARTER_MINUTES) - channels.updateTimeout(this.currentGroup.group, apiutil.QUARTER_MINUTES) - - const newTimeout = channels.getTimeout(this.currentGroup.group) - push.send([ - wireutil.global, - wireutil.pack(DeviceStatusChange, { - serial: options.serial, - timeout: newTimeout || undefined - }) - ]) - - return this.currentGroup + this.leave('takeover', false) } this.currentGroup = newGroup @@ -99,10 +83,9 @@ export default syrup.serial() alias: solo.channel }) - sub.subscribe(this.currentGroup.group) plugin.emit('join', this.currentGroup, keys) - push.send([ + transport.send([ wireutil.global, wireutil.pack(JoinGroupMessage, { serial: options.serial, @@ -120,29 +103,30 @@ export default syrup.serial() } } - leave = async(reason: string) => { + leave = (reason: string, send = true) => { if (!this.currentGroup) { - throw new grouputil.NoGroupError() + return null } log.important('No longer owned by "%s"', this.currentGroup.email) - log.info('Unsubscribing from group channel "%s"', this.currentGroup.group) - - push.send([ - wireutil.global, - wireutil.pack(LeaveGroupMessage, { - serial: options.serial, - owner: this.currentGroup, - reason - }) - ]) channels.unregister(this.currentGroup.group) - sub.unsubscribe(this.currentGroup.group) + + if (send) { + transport.send([ + wireutil.global, + wireutil.pack(LeaveGroupMessage, { + serial: options.serial, + owner: this.currentGroup, + reason + }) + ]) + } this.currentGroup = null - plugin.emit('leave', this.currentGroup) - return this.currentGroup + plugin.emit('leave', null) + + return null } beforeActionCheck = @@ -159,7 +143,7 @@ export default syrup.serial() this.beforeActionCheck(message) .catch((err: any) => { log.error('Error before processing %s: %s', msgName, err?.message) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -176,8 +160,8 @@ export default syrup.serial() return } - await plugin.join(message.owner!, message.timeout!, message.usage!, message.keys) - push.send([ + plugin.join(message.owner!, message.timeout!, message.usage!, message.keys) + transport.send([ channel, reply.okay() ]) @@ -185,25 +169,13 @@ export default syrup.serial() catch (err: any) { log.error('Failed processing GroupMessage: %s', err?.message) if (err instanceof grouputil.AlreadyGroupedError) { - push.send([ + transport.send([ channel, reply.fail(err.message) ]) } } }) - .on(AutoGroupMessage, async(channel, message) => { - try { - await plugin.join(message.owner!, 0, message.identifier, []) - plugin.emit('autojoin', message.identifier, true) - } - catch (err: any) { - log.error('Failed processing AutoGroupMessage: %s', err?.message) - if (err instanceof grouputil.AlreadyGroupedError) { - plugin.emit('autojoin', message.identifier, false) - } - } - }) .on(UngroupMessage, async(channel, message) => { const reply = wireutil.reply(options.serial) try { @@ -211,8 +183,8 @@ export default syrup.serial() return } - await plugin.leave('ungroup_request') - push.send([ + plugin.leave('ungroup_request') + transport.send([ channel, reply.okay() ]) @@ -220,7 +192,7 @@ export default syrup.serial() catch (err: any) { log.error('Failed processing UngroupMessage: %s', err?.message) if (err instanceof grouputil.NoGroupError) { - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -228,7 +200,16 @@ export default syrup.serial() } }) - // @ts-ignore + // Any inbound device message counts as activity: refresh the current + // group's lease so an actively-used device doesn't time out. Under + // ROUTER/DEALER the router no longer sees a per-channel subscription, so + // this activity-based keepalive lives here where the current group is + // known (the router's emitted "channel" is a correlationId, not a group + // channel). The lease is refreshed, not extended (see ChannelManager). + router.on('message', () => { + plugin.keepalive() + }) + channels.on('timeout', async(channel) => { const currentGroup = await plugin.get() if (currentGroup && channel === currentGroup.group) { @@ -238,7 +219,7 @@ export default syrup.serial() lifecycle.observe(async() => { try { - await plugin.leave('device_absent') + plugin.leave('device_absent') } catch (err: any) { log.error('Failed leave from group on process exit: %s', err?.message) diff --git a/lib/units/base-device/plugins/heartbeat.ts b/lib/units/base-device/plugins/heartbeat.ts index f4ad7aa969..4f21292c16 100644 --- a/lib/units/base-device/plugins/heartbeat.ts +++ b/lib/units/base-device/plugins/heartbeat.ts @@ -1,12 +1,12 @@ import syrup from '@devicefarmer/stf-syrup' import lifecycle from '../../../util/lifecycle.js' import wireutil from '../../../wire/util.js' -import push from '../support/push.js' +import transport from '../support/transport.js' import EventEmitter from 'events' import {DeviceHeartbeatMessage} from "../../../wire/wire.js" export default syrup.serial() - .dependency(push) - .define((options, push) => { + .dependency(transport) + .define((options, transport) => { const emitter = new EventEmitter<{ beat: [] }>() @@ -18,7 +18,7 @@ export default syrup.serial() let timer: NodeJS.Timeout const beat = () => ( timer = setTimeout(() => { - push.send(payload) + transport.send(payload) beat() emitter.emit('beat') }, options.heartbeatInterval) diff --git a/lib/units/base-device/plugins/solo.ts b/lib/units/base-device/plugins/solo.ts index a1c70653b0..79c35d42c3 100755 --- a/lib/units/base-device/plugins/solo.ts +++ b/lib/units/base-device/plugins/solo.ts @@ -1,17 +1,12 @@ import crypto from 'crypto' import syrup from '@devicefarmer/stf-syrup' -import logger from '../../../util/logger.js' import wireutil from '../../../wire/util.js' -import sub from '../support/sub.js' -import push from '../support/push.js' +import transport from '../support/transport.js' import {DeviceReadyMessage} from "../../../wire/wire.js" export default syrup.serial() - .dependency(sub) - .dependency(push) - .define((options, sub, push) => { - const log = logger.createLogger('base-device:plugins:solo') - + .dependency(transport) + .define((options, transport) => { // The channel should keep the same value between restarts, so that // having the client side up to date all the time is not horribly painful. const makeChannelId = () => { @@ -20,15 +15,15 @@ export default syrup.serial() return hash.digest('base64') } + // The channel identifies this device to the client side. Routing is by + // deviceKey now, so there is no channel subscription — the id is still + // published in DeviceReadyMessage for the client to address replies. const channel = makeChannelId() - log.info('Subscribing to permanent channel "%s"', channel) - sub.subscribe(channel) - return { channel: channel, poke: () => { - push.send([ + transport.send([ wireutil.global, wireutil.pack(DeviceReadyMessage, { serial: options.serial, diff --git a/lib/units/base-device/support/connector.js b/lib/units/base-device/support/connector.js index 4e48ca0bd6..46cd8e560f 100755 --- a/lib/units/base-device/support/connector.js +++ b/lib/units/base-device/support/connector.js @@ -5,7 +5,7 @@ import wire from '../../../wire/index.js' import dbapi from '../../../db/api.js' import wireutil from '../../../wire/util.js' import db from '../../../db/index.js' -import push from './push.js' +import transport from './transport.js' import {ConnectGetForwardUrlMessage, ConnectStartMessage, ConnectStopMessage} from '../../../wire/wire.js' /** @@ -34,8 +34,8 @@ export const DEVICE_TYPE = { export default syrup.serial() .dependency(router) - .dependency(push) - .define(async(options, router, push) => { + .dependency(transport) + .define(async(options, router, transport) => { const log = logger.createLogger('device:support:connector') return /** @type {ConnectorPlugin} */ new (class { started = false @@ -66,7 +66,7 @@ export default syrup.serial() getUrl = async(channel) => { if (this.started && this.url) { - push.send([ + transport.send([ channel, this.reply?.okay(this.url) ]) return @@ -82,7 +82,7 @@ export default syrup.serial() } this.url = await this.handlers.start() - if (this.deviceType === DEVICE_TYPE.ANDROID) { + if (!options.connectUrlPattern && this.deviceType === DEVICE_TYPE.ANDROID) { await db.connect() // TODO: remove db connect const device = await dbapi.loadDeviceBySerial(this.serial) if (device.adbPort && this.storageUrl) { @@ -94,12 +94,12 @@ export default syrup.serial() } } - push.send([ + transport.send([ channel, this.reply?.okay(this.url) ]) // Update DB - push.send([ + transport.send([ channel, wireutil.envelope(new wire.ConnectStartedMessage( this.serial, this.url @@ -119,13 +119,13 @@ export default syrup.serial() await this.handlers.stop() this.started = false - push.send([ + transport.send([ channel, this.reply?.okay() ]) // Update DB - push.send([ + transport.send([ channel, wireutil.envelope(new wire.ConnectStoppedMessage(this.serial)) ]) @@ -134,7 +134,7 @@ export default syrup.serial() } catch (/** @type {any} */e) { log.important('Remote Connect Stopping for device "%s" failed: %s', this.serial, e) - push.send([ + transport.send([ channel, this.reply?.fail(e?.message || e) ]) diff --git a/lib/units/base-device/support/logger.js b/lib/units/base-device/support/logger.js deleted file mode 100755 index 2cacee22e3..0000000000 --- a/lib/units/base-device/support/logger.js +++ /dev/null @@ -1,26 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' -import wireutil from '../../../wire/util.js' -import push from '../../base-device/support/push.js' -export default syrup.serial() - .dependency(push) - .define((options, push) => { - // Show serial number in logs - logger.setGlobalIdentifier(options.serial) - - if (!options.disableLogsOverWire) { - // Forward all logs - logger.on('entry', entry => { - push.send([ - wireutil.global, - wireutil.envelope(new wire.DeviceLogMessage(options.serial, entry.timestamp / 1000, entry.priority, entry.tag, entry.pid, entry.message, entry.identifier)) - ]) - }) - } - else { - const l = logger.createLogger('device:logger') - l.warn('Pushing logs over wire is disabled') - } - return logger - }) diff --git a/lib/units/base-device/support/push.ts b/lib/units/base-device/support/push.ts deleted file mode 100755 index 65c066af5a..0000000000 --- a/lib/units/base-device/support/push.ts +++ /dev/null @@ -1,39 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import logger from '../../../util/logger.js' -import srv from '../../../util/srv.ts' -import lifecycle from '../../../util/lifecycle.js' -import * as zmqutil from '../../../util/zmqutil.js' -import {SocketWrapper} from '../../../util/zmqutil.js' -export default syrup.serial().define( - async(options: { - endpoints: { - push: string[]; - }; - }): Promise => { - const log = logger.createLogger('device:support:push') - // Output - try { - const push = zmqutil.socket('push') - await Promise.all( - options.endpoints.push.map((endpoint) => - srv.resolve(endpoint).then((records) => - srv.attempt(records, (record) => { - log.info( - 'Device sending output to "%s"', - record.url - ) - push.connect(record.url) - return Promise.resolve(true) - }) - ) - ) - ) - - return push - } - catch (err) { - log.fatal('Unable to connect to sub endpoint: %s', err) - return lifecycle.fatal() // kill process - } - } -) diff --git a/lib/units/base-device/support/router.js b/lib/units/base-device/support/router.js index ee9bb7a68a..52f50b941e 100755 --- a/lib/units/base-device/support/router.js +++ b/lib/units/base-device/support/router.js @@ -1,16 +1,10 @@ import syrup from '@devicefarmer/stf-syrup' import {WireRouter} from '../../../wire/router.js' -import sub from './sub.js' -import channels from './channels.js' +import transport from './transport.js' export default syrup.serial() - .dependency(sub) - .dependency(channels) - .define((options, sub, channels) => { + .dependency(transport) + .define((options, transport) => { const router = new WireRouter() - sub.on('message', router.handler()) - // Special case, we're hooking into a message that's not actually routed. - router.on('message', channel => { - channels.keepalive(channel) - }) + transport.on('message', router.handler()) return router }) diff --git a/lib/units/base-device/support/sub.js b/lib/units/base-device/support/sub.js deleted file mode 100644 index 28dfe2fb5d..0000000000 --- a/lib/units/base-device/support/sub.js +++ /dev/null @@ -1,36 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import logger from '../../../util/logger.js' -import wireutil from '../../../wire/util.js' -import srv from '../../../util/srv.js' -import lifecycle from '../../../util/lifecycle.js' -import * as zmqutil from '../../../util/zmqutil.js' -export default syrup.serial() - // @ts-ignore - .define(/** @returns {Promise} */ async(options) => { - const log = logger.createLogger('base-device:support:sub') - // Input - const sub = zmqutil.socket('sub') - try { - await Promise.all(options.endpoints.sub.map(endpoint => - srv.resolve(endpoint).then(records => - srv.attempt(records, record => { - log.info('Receiving input from "%s"', record.url) - sub.connect(record.url) - return Promise.resolve(true) - }) - ) - )) - - // Establish always-on channels - ;[wireutil.global].forEach(channel => { - log.info('Subscribing to permanent channel "%s"', channel) - sub.subscribe(channel) - }) - - return sub - } - catch (err) { - log.fatal('Unable to connect to sub endpoint', err) - lifecycle.fatal(err) - } - }) diff --git a/lib/units/base-device/support/transport.ts b/lib/units/base-device/support/transport.ts new file mode 100644 index 0000000000..bb3953d21e --- /dev/null +++ b/lib/units/base-device/support/transport.ts @@ -0,0 +1,49 @@ +import os from 'os' +import syrup from '@devicefarmer/stf-syrup' +import logger from '../../../util/logger.js' +import srv from '../../../util/srv.js' +import lifecycle from '../../../util/lifecycle.js' +import {DealerSocket} from '../../../util/zmqsocket.js' +import {deviceKey} from '../../../wire/frame.js' +import {DeviceTransport} from '../../../wire/device-transport.js' + +const log = logger.createLogger('base-device:support:transport') + +export {DeviceTransport} from '../../../wire/device-transport.js' + +export interface DeviceTransportOptions { + serial: string + provider?: string + endpoints: { + // processor ROUTER endpoint(s) to connect to (SRV-resolvable). + processor: string[] + } +} + +export default syrup.serial().define( + async(options: DeviceTransportOptions): Promise => { + const providerName = options.provider ?? os.hostname() + const routingId = deviceKey(providerName, options.serial) + + const dealer = new DealerSocket({routingId, probeRouter: true}) + try { + await Promise.all(options.endpoints.processor.map(endpoint => + srv.resolve(endpoint).then(records => + srv.attempt(records, record => { + log.info('Device "%s" connecting to processor "%s"', routingId, record.url) + dealer.connect(record.url) + return Promise.resolve(true) + }) + ) + )) + } + catch (err: any) { + log.fatal('Unable to connect to processor endpoint: %s', err?.message || err) + return lifecycle.fatal() + } + + const transport = new DeviceTransport(dealer) + lifecycle.observe(() => transport.close()) + return transport + } +) diff --git a/lib/units/base-device/support/urlformat.js b/lib/units/base-device/support/urlformat.js deleted file mode 100644 index 35aa5ed2d9..0000000000 --- a/lib/units/base-device/support/urlformat.js +++ /dev/null @@ -1,24 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import _ from 'lodash' -import * as tr from 'transliteration' - -export default syrup.serial() - .define((options) => { - const createSlug = (model, name) => - (name === '' || model.toLowerCase() === name.toLowerCase()) ? - tr.slugify(model) : - tr.slugify(name + ' ' + model) - - return (template, port, model = null, name = null) => - _.template(template, { - imports: { - slugify: tr.slugify - } - })( - Object.assign({ - model, name, - slug: name || model ? createSlug(model, name) : 'slug', - publicPort: port - }, options) - ) - }) diff --git a/lib/units/base-device/support/urlformat.ts b/lib/units/base-device/support/urlformat.ts new file mode 100644 index 0000000000..2eb13c21bf --- /dev/null +++ b/lib/units/base-device/support/urlformat.ts @@ -0,0 +1,43 @@ +import syrup from '@devicefarmer/stf-syrup' +import _ from 'lodash' +import * as tr from 'transliteration' + +export type UrlFormatter = ( + template: string, + port: number, + model?: string | null, + name?: string | null, +) => string + +interface UrlFormatOptions { + [key: string]: unknown +} + +const createSlug = (model: string, name: string): string => + (name === '' || model.toLowerCase() === name.toLowerCase()) ? + tr.slugify(model) : + tr.slugify(name + ' ' + model) + +export default syrup.serial() + .define((options: UrlFormatOptions): UrlFormatter => + (template, port, model = null, name = null) => { + const safeModel = model ?? '' + const safeName = name ?? '' + const slug = (safeName || safeModel) ? createSlug(safeModel, safeName) : 'slug' + + return _.template(template, { + imports: { + slugify: tr.slugify, + }, + })( + Object.assign( + { + model: safeModel, + name: safeName, + slug, + publicPort: port, + }, + options, + ), + ) + }) diff --git a/lib/units/device/index.ts b/lib/units/device/index.ts index 768673cc49..4ee2b8b151 100644 --- a/lib/units/device/index.ts +++ b/lib/units/device/index.ts @@ -1,6 +1,7 @@ +import os from 'os' import syrup from '@devicefarmer/stf-syrup' import lifecycle from '../../util/lifecycle.js' -import logger$0 from '../base-device/support/logger.js' +import logger from '../../util/logger.js' import heartbeat from '../base-device/plugins/heartbeat.js' import solo from './plugins/solo.js' import stream from './plugins/screen/stream.js' @@ -15,7 +16,7 @@ import mute from './plugins/mute.js' import shell from './plugins/shell.js' import touch from './plugins/touch/index.js' import install from './plugins/install.js' -import forward from './plugins/forward/index.js' + import group from './plugins/group.js' import cleanup from './plugins/cleanup.js' import reboot from './plugins/reboot.js' @@ -30,50 +31,66 @@ import mobileService from './plugins/mobile-service.js' import remotedebug from './plugins/remotedebug.js' import {trackModuleReadyness} from './readyness.js' import wireutil from '../../wire/util.js' -import push from '../base-device/support/push.js' +import transport from '../base-device/support/transport.js' import adb from './support/adb.js' import router from '../base-device/support/router.js' -import {DeviceIntroductionMessage, DeviceRegisteredMessage, ProviderMessage} from "../../wire/wire.js"; +import { + DeviceAbsentMessage, + DeviceIntroductionMessage, + DeviceRegisteredMessage, + ProviderMessage +} from "../../wire/wire.js"; export default (function(options: any) { return syrup.serial() - // We want to send logs before anything else starts happening - .dependency(logger$0) - .dependency(push) + .dependency(transport) .dependency(adb) .dependency(router) .dependency(trackModuleReadyness('solo', solo)) - .define(async(options, logger, push, adb, router, solo) => { + .define(async(options, transport, adb, router, solo) => { + logger.setGlobalIdentifier(options.serial) const log = logger.createLogger('device') log.info('Preparing device') - if (!options.provider) { - let listener: ((...args: any[]) => void) | null = null - const waitRegister = Promise.race([ - new Promise(resolve => - router.on(DeviceRegisteredMessage, listener = (...args: any[]) => resolve(args)) - ), - new Promise(r => setTimeout(r, 15000)) - ]) + // Every device self-registers over its own DEALER. + // The DEALER's routing identity is deviceKey(providerName, serial), + // which is what the processor uses to derive presence. + const providerName = options.provider ?? os.hostname() + let registerListener: ((...args: any[]) => void) | null = null + const waitRegister = Promise.race([ + new Promise(resolve => + router.on(DeviceRegisteredMessage, registerListener = (...args: any[]) => resolve(args)) + ), + new Promise(r => setTimeout(r, 15000)) + ]) + + const type = await adb.getDevice(options.serial).getState() + transport?.send([ + wireutil.global, + wireutil.pack(DeviceIntroductionMessage, { + serial: options.serial, + // @ts-ignore + status: wireutil.toDeviceStatus(type), + provider: ProviderMessage.create({ + channel: solo.channel, + name: providerName + }) + }) + ]) - const type = await adb.getDevice(options.serial).getState() - push?.send([ + await waitRegister + router.removeListener(DeviceRegisteredMessage, registerListener!) + registerListener = null + + lifecycle.observeFatal(() => { + transport.send([ wireutil.global, - wireutil.pack(DeviceIntroductionMessage, { + wireutil.pack(DeviceAbsentMessage, { serial: options.serial, - // @ts-ignore - status: wireutil.toDeviceStatus(type), - provider: { - channel: solo.channel, - name: `standalone-${options.serial}` - } + presenceChangedAt: Date.now() }) ]) - - await waitRegister - router.removeListener(DeviceRegisteredMessage, listener!) - listener = null - } + }) return syrup.serial() .dependency(trackModuleReadyness('heartbeat', heartbeat)) @@ -89,7 +106,7 @@ export default (function(options: any) { .dependency(trackModuleReadyness('shell', shell)) .dependency(trackModuleReadyness('touch', touch)) .dependency(trackModuleReadyness('install', install)) - .dependency(trackModuleReadyness('forward', forward)) + .dependency(trackModuleReadyness('group', group)) .dependency(trackModuleReadyness('cleanup', cleanup)) .dependency(trackModuleReadyness('reboot', reboot)) diff --git a/lib/units/device/plugins/account.js b/lib/units/device/plugins/account.js index 52d9951393..4f78189252 100644 --- a/lib/units/device/plugins/account.js +++ b/lib/units/device/plugins/account.js @@ -1,12 +1,11 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import service from './service.js' import identity from './util/identity.js' import touch from './touch/index.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import adb from '../support/adb.js' import {AccountAddMenuMessage, AccountAddMessage, AccountCheckMessage, AccountGetMessage, AccountRemoveMessage} from '../../../wire/wire.js' export default syrup.serial() @@ -14,9 +13,9 @@ export default syrup.serial() .dependency(identity) .dependency(touch) .dependency(router) - .dependency(push) + .dependency(transport) .dependency(adb) - .define(function(options, service, identity, touch, router, push, adb) { + .define(function(options, service, identity, touch, router, transport, adb) { var log = logger.createLogger('device:plugins:account') function checkAccount(type, account) { return service.getAccounts({type: type}) @@ -33,14 +32,14 @@ export default syrup.serial() log.info('Checking if account "%s" is added', message.account) checkAccount(message.type, message.account) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Account check failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -52,14 +51,14 @@ export default syrup.serial() service.getAccounts(message) .timeout(30000) .then(function(accounts) { - push.send([ + transport.send([ channel, reply.okay('success', accounts) ]) }) .catch(function(err) { log.error('Account get failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -71,14 +70,14 @@ export default syrup.serial() service.removeAccount(message) .timeout(30000) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Account removal failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -90,14 +89,14 @@ export default syrup.serial() service.addAccountMenu() .timeout(30000) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Add account menu failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -263,7 +262,7 @@ export default syrup.serial() // First check if the account is already added so we don't continue return checkAccount(type, account) .then(function() { - push.send([ + transport.send([ channel, reply.fail('Add account failed: account was already added') ]) @@ -295,14 +294,14 @@ export default syrup.serial() return checkAccount(type, account) }) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { - log.error('Add account failed', err.stack) - push.send([ + log.error('Add account failed %s', err.message) + transport.send([ channel, reply.fail('Add account failed: ' + err.message) ]) diff --git a/lib/units/device/plugins/adb-reverse/forwards.ts b/lib/units/device/plugins/adb-reverse/forwards.ts new file mode 100644 index 0000000000..e89967635c --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/forwards.ts @@ -0,0 +1,169 @@ +/** + * Mirror of the reverse-forward allow-list that the user's own adb server keeps + * for our transport (AOSP `atransport::UpdateReverseConfig`, transport.cpp:1640). + * + * The user's server snoops every `reverse:` request before relaying it to us and + * records `remote -> local`. Later, when we push an A_OPEN back at it, it checks + * the service name against those `local` specs and calls LOG(FATAL) — killing + * the user's adb server — for anything it does not recognise. So we must parse + * with the same grammar and never open a reverse stream for a spec we have not + * seen registered here. + */ + +export type BindCommand = {action: 'bind', remote: string, local: string, devicePort: number} +export type UnbindCommand = {action: 'unbind', remote: string} +export type UnbindAllCommand= {action: 'unbind-all'} +export type ListCommand = {action: 'list'} +export type UnknownCommand = {action: 'unknown', name: string} +/** + * A `forward:` request whose spec did not parse. AOSP answers this itself rather + * than falling through to reverse_service's generic message, and quotes the spec + * back with the `forward:`/`norebind:` prefixes already stripped (adb.cpp:1190). + */ +export type BadForwardCommand = {action: 'bad-forward', spec: string} +/** + * A `killforward:` request whose spec is empty or has more than one piece. Same + * split as `forward:`, but AOSP demands exactly one non-empty piece here + * (adb.cpp:1184), and quotes the spec back without the `killforward:` prefix. + */ +export type BadKillforwardCommand = {action: 'bad-killforward', spec: string} +/** `norebind:` asked for a remote that is already bound (INSTALL_STATUS_CANNOT_REBIND). */ +export type RebindRefusedCommand = {action: 'rebind-refused', remote: string} +/** Well-formed request we cannot serve: minirev can only bind a TCP port on the device. */ +export type UnsupportedCommand = {action: 'unsupported', remote: string} +/** `killforward:` named a remote nobody had bound (INSTALL_STATUS_LISTENER_NOT_FOUND). */ +export type ListenerNotFoundCommand = {action: 'listener-not-found', remote: string} + +export type ReverseCommand = + | BindCommand + | UnbindCommand + | UnbindAllCommand + | ListCommand + | UnknownCommand + | BadForwardCommand + | BadKillforwardCommand + | RebindRefusedCommand + | UnsupportedCommand + | ListenerNotFoundCommand + +/** Parse `reverse:` service name, mirror the map, return the typed command. */ +export class ReverseForwards { + private byRemote = new Map() + + /** + * Parse the ADB service name (the full "reverse:..." string) and update the + * map in-place. Returns a discriminated union so callers can act on type. + */ + exec(name: string): ReverseCommand { + if (!name.startsWith('reverse:')) { + return {action: 'unknown', name} + } + const rest = name.slice('reverse:'.length) + + if (rest === 'killforward-all') { + this.byRemote.clear() + return {action: 'unbind-all'} + } + + if (rest === 'list-forward') { + return {action: 'list'} + } + + if (rest.startsWith('killforward:')) { + const remote = rest.slice('killforward:'.length) + // killforward: takes exactly one non-empty piece (adb.cpp:1183). + // Like a malformed forward:, this is a failure AOSP reports itself, + // with its own text — not the generic "not a reverse forwarding + // command" it uses for a prefix it does not recognise at all. + if (!remote || remote.includes(';')) { + return {action: 'bad-killforward', spec: remote} + } + // remove_listener reports INSTALL_STATUS_LISTENER_NOT_FOUND when + // nothing was bound; a silent success would hide the user's typo. + if (!this.byRemote.delete(remote)) { + return {action: 'listener-not-found', remote} + } + return {action: 'unbind', remote} + } + + if (rest.startsWith('forward:')) { + let spec = rest.slice('forward:'.length) + // optional norebind: prefix + let norebind = false + if (spec.startsWith('norebind:')) { + norebind = true + spec = spec.slice('norebind:'.length) + } + // AOSP splits on ";" and demands exactly two non-empty pieces, with + // the local spec not starting with '*' (adb.cpp:1188). Accepting a + // third piece would register a local spec the user's own server never + // snooped — and it LOG(FATAL)s on an A_OPEN it cannot match. + // + // This is a `bad-forward`, not an `unknown`: handle_forward_request + // recognises the command and reports the failure itself, so the user + // sees "bad forward: " rather than reverse_service's generic + // fallback. The spec is quoted with the prefixes already stripped, + // because AOSP has advanced its own pointer past them by this point. + const pieces = spec.split(';') + if (pieces.length !== 2 || !pieces[0] || !pieces[1] || pieces[1].startsWith('*')) { + return {action: 'bad-forward', spec} + } + const [remote, local] = pieces + if (norebind && this.byRemote.has(remote)) { + // A locally connected device answers FAIL here + // (INSTALL_STATUS_CANNOT_REBIND); the old target stays bound. + return {action: 'rebind-refused', remote} + } + // minirev can only bind a TCP port on the device, so a well-formed + // request for anything else has to be refused explicitly rather than + // registered against a bogus port. + const devicePort = portFromSpec(remote) + if (!devicePort) { + return {action: 'unsupported', remote} + } + this.byRemote.set(remote, local) + return {action: 'bind', remote, local, devicePort} + } + + return {action: 'unknown', name} + } + + /** Return the `local` spec registered for `remote`, or undefined. */ + resolveLocal(remote: string): string | undefined { + return this.byRemote.get(remote) + } + + /** Check whether `local` is in the allow-list (used before sending A_OPEN). */ + isLocalConfigured(local: string): boolean { + for (const v of this.byRemote.values()) { + if (v === local) return true + } + return false + } + + /** Current size (number of registered forwards). */ + get size(): number { + return this.byRemote.size + } + + entries(): IterableIterator<[string, string]> { + return this.byRemote.entries() + } +} + +/** + * Extract the numeric port from a `tcp:` spec. + * Returns 0 for anything we cannot bind on the device: a non-tcp spec, a + * non-numeric port, or a port outside the valid 1-65535 range. + */ +function portFromSpec(spec: string): number { + if (!spec.startsWith('tcp:')) { + return 0 + } + const digits = spec.slice(4) + if (!/^\d+$/.test(digits)) { + return 0 + } + const port = Number(digits) + return port >= 1 && port <= 65535 ? port : 0 +} diff --git a/lib/units/device/plugins/adb-reverse/manager.ts b/lib/units/device/plugins/adb-reverse/manager.ts new file mode 100644 index 0000000000..23bbe3b7c3 --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/manager.ts @@ -0,0 +1,171 @@ +/** + * All reverse connections arriving on one bound reverse forward. + * + * One instance per `adb reverse` binding, owning the minirev stream that + * multiplexes every connection made to that device port. On a new connection + * `DestHandler` (in the old forward plugin) would call net.connect() from the + * provider host; here we push an A_OPEN at the user so the connection is made + * on *their* machine. + */ + +import {ReverseStream, type ReverseSocket, type ReverseDevice} from './stream.ts' +import type {ReverseForwards} from './forwards.ts' + +/** minirev's frame header: [uint16LE connId][uint16LE length] (writer.js:3). */ +const FRAME_HEADER_SIZE = 4 + +/** + * The rest of adbkit's tcpusb Socket we need: the id counter and the service + * map. Both must be the socket's own — ids are shared with the client's streams, + * and `_forwardServicePacket` routes incoming packets through that same map. + */ +export interface ReverseHostSocket extends ReverseSocket { + remoteId: {next(): number} + services: { + insert(localId: number, handler: {handle(packet: unknown): void, end(): void}): unknown + remove(localId: number): unknown + } +} + +/** The minirev stream for this binding; framing is applied here, not by callers. */ +export interface ReverseConn { + write(chunk: Buffer): boolean + end(): void +} + +export class ReverseStreamManager { + private streams = new Map() + /** True once the forward is torn down; no further connection may be opened. */ + private ended = false + + constructor( + private socket: ReverseHostSocket, + private remote: string, + private forwards: ReverseForwards, + private conn: ReverseConn + ) {} + + /** + * A minirev frame, as MinirevReader emits it: ('packet', connId, data), with + * `null` for the zero-length FIN frame. + */ + handlePacket(connId: number, data: Buffer | null): void { + // Frames outlive the teardown. conn.end() only half-closes the stream, so + // whatever minirev had already put on the wire still comes through + // MinirevReader afterwards. Opening a stream for one of them would ask the + // user's server to connect for a forward it has already dropped from + // reverse_forwards_ — LOG(FATAL), their adb dies (adb.cpp:500) — and would + // register a handler in a ServiceMap nobody drains any more. + if (this.ended) { + return + } + + let stream = this.streams.get(connId) + + if (!stream) { + // The local spec is resolved per connection, never cached: a + // killforward between the bind and this connection must stop us from + // opening a stream the user's server no longer allows — it answers an + // unrecognised A_OPEN with LOG(FATAL) (adb.cpp:500). + const local = this.forwards.resolveLocal(this.remote) + if (!local) { + return + } + const localId = this.socket.remoteId.next() + stream = new ReverseStream(this.socket, localId, local, this.deviceSink(connId), () => { + // Slice 6: remove from both maps when the stream closes, from either + // side. Without this ServiceMap.insert will throw on id reuse when + // RollingCounter wraps, and this.streams leaks one entry per request. + this.socket.services.remove(localId) + this.streams.delete(connId) + }) + // connId is the connection's identity for its whole life: every later + // frame of it must find this same stream, not open a second one. + this.streams.set(connId, stream) + // Registered before the A_OPEN goes out, not after: adbkit routes + // every A_OKAY/A_WRTE/A_CLSE by arg1 through this map + // (socket.js:_forwardServicePacket), so the answer to our open has + // nowhere to land until the entry exists. + this.socket.services.insert(localId, stream) + stream.open() + } + + if (data) { + stream.fromDevice(data) + } + else { + stream.fromDeviceEnd() + } + } + + /** + * The forward itself is going away — `adb reverse --remove`, the end of the + * `adb connect` session, or the device leaving the group. + * + * Every connection still open under it has to be closed, in both directions: + * each stream sends its own A_CLSE to the user and its own FIN frame to the + * device, so nothing is left holding a socket that will never be served. + * Our counterpart in adbkit is ServiceMap.end (servicemap.js), which ends + * every registered handler the same way. + * + * Deleting from `this.streams` while iterating it is safe here: each close + * runs the stream's onEnd, which removes that stream's own entry, and a Map + * iterator skips entries deleted behind it rather than losing its place. + * ReverseStream.end is also guarded, so a stream that closed on its own in + * the meantime is simply a no-op. + * + * Then the minirev stream itself goes, because that stream *is* the binding: + * closing it is what makes the device stop listening on the port. Closing + * conn is the only unbind mechanism — without it the device keeps accepting + * connections for a removed forward, and we answer them with A_OPEN for a + * spec the user's server no longer has in reverse_forwards_ — LOG(FATAL), + * their adb dies (adb.cpp:500). + * + * The order is load-bearing: every stream writes its FIN frame into this + * same conn, so they must close while it is still open. + */ + end(): void { + this.ended = true + for (const stream of this.streams.values()) { + stream.end() + } + this.conn.end() + } + + /** + * The device end of one reverse connection: minirev's multiplexing frame, + * `[uint16LE connId][uint16LE length]` followed by the payload. + * + * Same layout MinirevWriter writes; minirev is a vendored binary with no + * source in the tree, so its own working client is the only specification + * for these four bytes. + * + * No splitting here, unlike MinirevWriter: these bytes come out of a single + * A_WRTE, and adbkit caps maxPayload at UINT16_MAX when it negotiates A_CNXN + * (socket.js:135), so the length always fits the 16-bit field. + */ + private deviceSink(connId: number): ReverseDevice { + return { + write: (chunk: Buffer) => { + const frame = Buffer.alloc(FRAME_HEADER_SIZE + chunk.length) + frame.writeUInt16LE(connId, 0) + frame.writeUInt16LE(chunk.length, 2) + chunk.copy(frame, FRAME_HEADER_SIZE) + this.conn.write(frame) + }, + // Slice 7: signal to the device that this connection is over by writing + // a zero-length frame — [uint16LE connId][uint16LE 0] — exactly what + // MinirevWriter._flush writes and MinirevReader turns back into + // ('packet', connId, null) on the other end. Without this the app + // on the device holds its socket open forever, waiting on a connection + // that was already closed on the user side. Note: this.conn is the shared minirev stream for + // the *whole* forward; we never call conn.end() here. + end: () => { + const fin = Buffer.alloc(FRAME_HEADER_SIZE) + fin.writeUInt16LE(connId, 0) + fin.writeUInt16LE(0, 2) + this.conn.write(fin) + }, + } + } +} diff --git a/lib/units/device/plugins/adb-reverse/minirevReader.ts b/lib/units/device/plugins/adb-reverse/minirevReader.ts new file mode 100644 index 0000000000..0f3a5c3d54 --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/minirevReader.ts @@ -0,0 +1,50 @@ +import {Transform, TransformCallback} from 'node:stream' + +const HEADER_SIZE = 4 + +/** + * Parses the minirev framing protocol: [uint16LE connId][uint16LE length][payload]. + * A length-0 frame signals connection close and emits ('packet', connId, null). + * + * Identical wire format to forward/util/reader.js — rewritten in TypeScript + * with class syntax; no util.inherits overhead. + */ +export class MinirevReader extends Transform { + private readonly _header = Buffer.alloc(HEADER_SIZE) + private _needLength = -HEADER_SIZE + private _target = 0 + + override _transform(chunk: Buffer, _encoding: string, done: TransformCallback): void { + let cursor = 0 + while (cursor < chunk.length) { + const diff = chunk.length - cursor + if (this._needLength < 0) { + // Still assembling a header + if (diff < -this._needLength) { + chunk.copy(this._header, HEADER_SIZE + this._needLength, cursor, cursor + diff) + break + } + chunk.copy(this._header, HEADER_SIZE + this._needLength, cursor, cursor + -this._needLength) + cursor += -this._needLength + this._target = this._header.readUInt16LE(0) + this._needLength = this._header.readUInt16LE(2) + if (this._needLength === 0) { + // FIN frame + this.emit('packet', this._target, null) + this._needLength = -HEADER_SIZE + } + } else if (diff >= this._needLength) { + // Full data frame + this.emit('packet', this._target, chunk.subarray(cursor, cursor + this._needLength)) + cursor += this._needLength + this._needLength = -HEADER_SIZE + } else { + // Partial data frame + this.emit('packet', this._target, chunk.subarray(cursor, cursor + diff)) + this._needLength -= diff + cursor += diff + } + } + done() + } +} diff --git a/lib/units/device/plugins/adb-reverse/minirevWriter.ts b/lib/units/device/plugins/adb-reverse/minirevWriter.ts new file mode 100644 index 0000000000..cc02e337ad --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/minirevWriter.ts @@ -0,0 +1,42 @@ +import {Transform, TransformCallback} from 'node:stream' + +const HEADER_SIZE = 4 +const MAX_PACKET_SIZE = 0xffff + +/** + * Frames outgoing data into minirev packets: [uint16LE connId][uint16LE length][payload]. + * On stream end, emits a FIN frame: [uint16LE connId][uint16LE 0]. + * + * Identical wire format to forward/util/writer.js — rewritten in TypeScript + * with class syntax; no util.inherits overhead. + */ +export class MinirevWriter extends Transform { + private readonly _target: number + + constructor(target: number) { + super() + this._target = target + } + + override _transform(fullChunk: Buffer, _encoding: string, done: TransformCallback): void { + let chunk = fullChunk + do { + const length = Math.min(MAX_PACKET_SIZE, chunk.length) + const header = Buffer.allocUnsafe(HEADER_SIZE) + header.writeUInt16LE(this._target, 0) + header.writeUInt16LE(length, 2) + this.push(header) + this.push(chunk.subarray(0, length)) + chunk = chunk.subarray(length) + } while (chunk.length) + done() + } + + override _flush(done: TransformCallback): void { + const header = Buffer.allocUnsafe(HEADER_SIZE) + header.writeUInt16LE(this._target, 0) + header.writeUInt16LE(0, 2) + this.push(header) + done() + } +} diff --git a/lib/units/device/plugins/adb-reverse/packet.ts b/lib/units/device/plugins/adb-reverse/packet.ts new file mode 100644 index 0000000000..c4b6eb667c --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/packet.ts @@ -0,0 +1,64 @@ +/** + * Minimal ADB wire-packet codec. + * + * `@u4/adbkit` already ships this logic in `dist/adb/tcpusb/packet.js`, but its + * package.json declares only "." in `exports`, so under ESM there is no way to + * import it. Rather than patch the dependency, we re-implement the few bytes we + * need. The unit tests pin every function against adbkit's own implementation, + * because adbkit is what parses the packets we emit: a wrong magic or checksum + * makes the user's adb server drop the connection. + * + * Header layout (24 bytes, little-endian uint32 each): + * 0 command | 4 arg0 | 8 arg1 | 12 length | 16 checksum | 20 magic | 24.. payload + */ + +export const A_SYNC = 0x434e5953 +export const A_CNXN = 0x4e584e43 +export const A_OPEN = 0x4e45504f +export const A_OKAY = 0x59414b4f +export const A_CLSE = 0x45534c43 +export const A_WRTE = 0x45545257 +export const A_AUTH = 0x48545541 + +const HEADER_SIZE = 24 + +/** Naive sum of the payload bytes — ADB's "checksum", not a CRC. */ +export function checksum(data?: Buffer): number { + let sum = 0 + if (data) { + for (let i = 0, len = data.length; i < len; i++) { + sum += data[i] + } + } + return sum +} + +/** The header's magic field: the command with every bit flipped. */ +export function magic(command: number): number { + // ">>> 0" keeps the result in the unsigned uint32 range + return (command ^ 0xffffffff) >>> 0 +} + +/** Encode a packet, byte-for-byte identical to adbkit's Packet.assemble. */ +export function assemble(command: number, arg0: number, arg1: number, data?: Buffer): Buffer { + if (data) { + const chunk = Buffer.alloc(HEADER_SIZE + data.length) + chunk.writeUInt32LE(command, 0) + chunk.writeUInt32LE(arg0, 4) + chunk.writeUInt32LE(arg1, 8) + chunk.writeUInt32LE(data.length, 12) + chunk.writeUInt32LE(checksum(data), 16) + chunk.writeUInt32LE(magic(command), 20) + data.copy(chunk, HEADER_SIZE) + return chunk + } + + const chunk = Buffer.alloc(HEADER_SIZE) + chunk.writeUInt32LE(command, 0) + chunk.writeUInt32LE(arg0, 4) + chunk.writeUInt32LE(arg1, 8) + chunk.writeUInt32LE(0, 12) + chunk.writeUInt32LE(0, 16) + chunk.writeUInt32LE(magic(command), 20) + return chunk +} diff --git a/lib/units/device/plugins/adb-reverse/replies.ts b/lib/units/device/plugins/adb-reverse/replies.ts new file mode 100644 index 0000000000..0ee8d3718c --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/replies.ts @@ -0,0 +1,92 @@ +/** + * Byte-exact replies to `reverse:` requests, as a real adbd would send them. + * + * We intercept `reverse:` on the tcpusb bridge and answer it ourselves, so these + * bytes go straight into the user's `adb` client, which parses them with no + * tolerance. Every format here is pinned to AOSP: + * - `SendProtocolString` (adb_io.cpp): 4 hex length chars + payload + * - `SendOkay` / `SendFail` (adb.cpp:1245, adb_io.cpp) + * - `handle_forward_request` (adb.cpp:1133) decides which of them is sent + * + * Note the `#if ADB_HOST` blocks in `handle_forward_request`: on a device those + * are compiled out, so the device sends only ONE `OKAY` — the second one the + * user sees is generated by their own server when our A_OKAY opens the stream. + */ + +import type {ReverseCommand, ReverseForwards} from './forwards.ts' + +/** 4 lowercase hex length chars followed by the payload (SendProtocolString). */ +export function protocolString(s: string): Buffer { + const payload = Buffer.from(s, 'utf8') + const length = payload.length.toString(16).padStart(4, '0') + return Buffer.concat([Buffer.from(length, 'ascii'), payload]) +} + +const OKAY = Buffer.from('OKAY', 'ascii') + +/** "FAIL" followed by the reason as a protocol string (adb_io.cpp SendFail). */ +export function fail(reason: string): Buffer { + return Buffer.concat([Buffer.from('FAIL', 'ascii'), protocolString(reason)]) +} + +/** + * One line per registered forward, exactly as AOSP `format_listeners()` writes + * them: ` \n`. Entries created by + * `adb reverse` have no serial, so the first column is the literal "(reverse)". + * + * Note the column order: `local_name` is the spec the listener binds on the + * device (our `remote` key), and `connect_to` is where it forwards to (our + * `local`). + */ +export function formatListeners(forwards: ReverseForwards): string { + let result = '' + for (const [remote, local] of forwards.entries()) { + result += `(reverse) ${remote} ${local}\n` + } + return result +} + +/** + * The bytes a device sends back for a parsed `reverse:` command. + * + * `forwards` is only needed to answer `list-forward`; every other reply is a + * fixed string. Anything we did not explicitly accept must come back as FAIL — + * reporting OKAY for a request we refused would leave the user believing a + * forward exists, and we would never open a stream for it. + */ +export function replyFor(command: ReverseCommand, forwards?: ReverseForwards): Buffer { + switch (command.action) { + case 'bind': + case 'unbind': + case 'unbind-all': + return OKAY + case 'list': + // The only reply with no OKAY prefix: on a device the `#if ADB_HOST` + // SendOkay above it is compiled out (adb.cpp:1137-1143). + return protocolString(forwards ? formatListeners(forwards) : '') + case 'bad-forward': + // handle_forward_request recognises the `forward:` prefix and answers the + // parse failure itself (adb.cpp:1190) instead of declining the command, so + // this is NOT the "not a reverse forwarding command" fallback. The spec is + // quoted with `forward:`/`norebind:` already stripped, because AOSP has + // advanced its `service` pointer past them by then. + return fail(`bad forward: ${command.spec}`) + case 'bad-killforward': + // Same ownership as bad-forward, different text: the check demands + // exactly one non-empty piece, so a `;` in the spec lands here rather + // than being read as a second argument (adb.cpp:1184). + return fail(`bad killforward: ${command.spec}`) + case 'rebind-refused': + return fail('cannot rebind existing socket') + case 'listener-not-found': + return fail(`listener '${command.remote}' not found`) + case 'unsupported': + // A well-formed request we cannot serve is a bind failure, not a parse + // failure — minirev can only listen on a TCP port. + return fail('cannot bind listener: only tcp: specs are supported on this device') + case 'unknown': + // reverse_service's fallback when handle_forward_request declines the + // command (daemon/services.cpp:69). + return fail('not a reverse forwarding command') + } +} diff --git a/lib/units/device/plugins/adb-reverse/session.ts b/lib/units/device/plugins/adb-reverse/session.ts new file mode 100644 index 0000000000..3a7feccc10 --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/session.ts @@ -0,0 +1,269 @@ +/** + * The reverse side of one `adb connect` session. + * + * `reverse:` requests must never reach adbkit's `Service`, which relays every + * service name to the *provider's* adb server (service.js: client.getDevice() + * .transport()). That server snoops the request itself and the real adbd binds a + * listener on the device, so every reverse connection ends up on the provider + * host's loopback rather than the user's — a forward that appears to work while + * pointing at the wrong machine, and an opening onto the provider's local + * services (ADB-REVERSE-PLAN.md §3.5). We answer those requests here instead, + * and tunnel the connections back to the user inside this same socket. + * + * Everything lives in this module rather than in connect.ts because connect.ts + * is a syrup plugin with nine dependencies and no way to unit-test; the only + * things it needs to know are `attachReverse` and `session.end()`. + */ + +import type {ReversePacket} from './stream.ts' +import {ReverseStreamManager, type ReverseHostSocket, type ReverseConn} from './manager.ts' + +import {ReverseForwards} from './forwards.ts' +import {fail, replyFor} from './replies.ts' +import {assemble, A_OPEN, A_OKAY, A_WRTE, A_CLSE} from './packet.ts' +import {MinirevReader} from './minirevReader.ts' + +const REVERSE_PREFIX = 'reverse:' + +/** + * The minirev stream for one bound forward: a Duplex that can be piped into + * MinirevReader. Extends ReverseConn (what the manager needs) with pipe + * (what MinirevReader needs). Declared here rather than widening ReverseConn + * in manager.ts, so manager tests' fake conns do not have to implement pipe. + */ +export interface MinirevStream extends ReverseConn { + pipe(destination: T): T + on(event: string, listener: (...args: unknown[]) => void): unknown +} + +/** The minirev stream for one bound forward; injected so this module needs no adb. */ +export type OpenMinirev = (devicePort: number) => Promise + +/** + * The service name carried by an A_OPEN. + * + * adbkit's `_skipNull` drops exactly one trailing byte (socket.js:259-261), but + * AOSP calls StripTrailingNulls on the same field, so we follow AOSP and drop + * them all: the name was historically read as a char* that stopped at the first + * NUL, and a sender may pad it. + */ +function serviceName(data: Buffer): string { + let end = data.length + while (end > 0 && data[end - 1] === 0) { + end-- + } + return data.toString('utf8', 0, end) +} + +/** + * Whether this packet is a `reverse:` request we have to serve ourselves. + * + * Synchronous and side-effect free by design: the wrapper below has to decide + * whether to delegate before it may await anything, or packets would be + * reordered on their way to adbkit. + * + * A payload is only a service name on an A_OPEN — the same bytes inside an + * A_WRTE are user data (the stdin of a shell, a file being pushed), and claiming + * those would corrupt the stream they belong to. + */ +export function isReverseOpen(packet: ReversePacket): boolean { + if (packet.command !== A_OPEN || !packet.data) { + return false + } + return serviceName(packet.data).startsWith(REVERSE_PREFIX) +} + +/** The `reverse:` state of a single `adb connect` session. */ +export class ReverseSession { + /** + * This session's mirror of the user's own reverse allow-list. + * + * Strictly per-socket, never shared: the map is what decides which specs we + * may open on the user's machine, so one session seeing another's entries + * would be one user reaching another's loopback. + */ + private forwards = new ReverseForwards() + + /** + * The live state of each bound forward, keyed by remote spec. + * + * Presence is what tells a rebind from a fresh bind (fact 32): if the key is + * here the listener is up and the manager is running; if it is absent the + * bind either never happened or failed and was rolled back. + */ + private bound = new Map() + + constructor( + private socket: ReverseSessionSocket, + private openMinirev: OpenMinirev + ) {} + + /** + * Serve one intercepted `reverse:` A_OPEN. + * + * Three packets go back, which is what a real adbd puts on the wire for this + * service: reverse_service writes the reply into a socketpair and closes its + * end immediately (daemon/services.cpp:69-84), so the stream carries the + * open acknowledgement, the reply bytes, and a close. + * + * The stream is not registered in socket.services: it is over by the time we + * return, and the user's A_OKAY/A_CLSE for it will simply find nothing there, + * which adbkit logs as a packet for an already-closed service and drops + * (socket.js:245-248). The id still has to come from the socket's own counter + * so it cannot collide with a client stream. + */ + async serve(packet: ReversePacket): Promise { + const name = packet.data ? serviceName(packet.data) : '' + const command = this.forwards.exec(name) + + /** Set only when the bind failed; otherwise the reply comes from the map. */ + let bindFailure: Buffer | undefined + + // A rebind keeps the listener it already has. install_listener does not + // recreate one whose local_name matches: it overwrites connect_to and + // returns OK on the same fd (adb_listeners.cpp:190-215). `exec` has already + // rewritten the map, and ReverseStreamManager resolves the target per + // connection, so the existing stream picks the new one up by itself. + // Rebinding for real would be worse than the device we are imitating: a + // window where the port is not listening, and every live connection through + // it cut. + if (command.action === 'bind' && !this.bound.has(command.remote)) { + // The listener goes up before the user hears anything. A device runs + // install_listener to completion inside handle_forward_request and only + // then answers (adb.cpp:1205-1212), so it never reports OKAY for a + // listener that is not up. Answering first would leave the user holding + // a forward we cannot serve, and the A_OPEN we would later push for it + // names a spec their own server never snooped -> LOG(FATAL) + // (adb.cpp:500). The cost is one openLocal round-trip, which the user's + // adb client is blocked on anyway. + try { + // Recorded only once the stream is actually up. Marking the remote + // before awaiting would make a retry after a failed bind look like a + // rebind: we would answer OKAY, bind nothing, and leave the user with + // a forward in `--list` whose connections hang. + const conn = await this.openMinirev(command.devicePort) + const manager = new ReverseStreamManager(this.socket, command.remote, this.forwards, conn) + // Wire minirev frames through MinirevReader, which parses + // [uint16LE connId][uint16LE len] headers and emits + // ('packet', connId, data) — or ('packet', connId, null) for a + // zero-length FIN frame. The empty 'readable' listener is required: + // without it the Transform stays in paused mode and never emits 'end' + // (same pattern as ForwardHandler in the former forward plugin). + conn.pipe(new MinirevReader()) + .on('packet', (id: number, data: Buffer | null) => manager.handlePacket(id, data)) + .on('readable', () => {}) + this.bound.set(command.remote, {conn, manager}) + } + catch (err) { + // `exec` registered the forward as it parsed the request, so a bind + // that never came up has to be taken back out. The user's own server + // keeps its entry either way — it snoops the request when it sends it + // to us (sockets.cpp:560), long before it can know the outcome — so + // our map is always a subset of theirs, which is the safe direction: + // a spec they know and we do not costs nothing, while the reverse is + // the LOG(FATAL). What it does buy us is an honest + // `adb reverse --list`, matching a device where a failed + // install_listener leaves nothing in listener_list. + this.forwards.exec(`${REVERSE_PREFIX}killforward:${command.remote}`) + // INSTALL_STATUS_CANNOT_BIND: "cannot bind listener: %s", where %s is + // whatever the listen attempt reported (adb.cpp:1226-1228). replyFor + // has no branch for this — the parse succeeded, the bind is what + // failed — so the bytes are built here. + bindFailure = fail(`cannot bind listener: ${err instanceof Error ? err.message : String(err)}`) + } + } + + // Teardown for remove commands. killforward calls remove_listener which + // closes the listening fd on the device — the only mechanism available + // is conn.end() via manager.end() (fact 23). Skipping this leaves the + // device port listening and, when an app connects, we push A_OPEN with + // a spec the user's server no longer knows -> LOG(FATAL) (adb.cpp:500). + // + // Note: exec() already updated forwards before we get here, so + // listener-not-found means there is no manager to end. + if (command.action === 'unbind') { + const entry = this.bound.get(command.remote) + if (entry) { + entry.manager.end() + this.bound.delete(command.remote) + } + } else if (command.action === 'unbind-all') { + for (const {manager} of this.bound.values()) { + manager.end() + } + this.bound.clear() + } + + const localId = this.socket.remoteId.next() + const theirId = packet.arg0 + + this.socket.write(assemble(A_OKAY, localId, theirId)) + this.socket.write(assemble(A_WRTE, localId, theirId, bindFailure ?? replyFor(command, this.forwards))) + // arg0 is our id, not 0: the open did succeed — we acknowledged it above. + // Zero here is the protocol's failed-open form (adb.cpp:597). + this.socket.write(assemble(A_CLSE, localId, theirId)) + } + + /** Drop every forward this session bound. */ + end(): void { + for (const {manager} of this.bound.values()) { + manager.end() + } + this.bound.clear() + } +} + +/** + * Take `reverse:` requests over on an adbkit tcpusb Socket. + * + * The Socket wires its reader up in its constructor — + * `new PacketReader(this.socket).on('packet', this._handle.bind(this))` + * (socket.js:57-58) — and keeps no reference to that bound function, so the only + * way to get in front of it is to read the listeners off the reader, remove them + * and install one that delegates. That is safe to do from the 'connection' + * handler: `server.js` emits 'connection' synchronously from the Socket + * constructor, while PacketReader defers its first read to setImmediate + * (packetreader.js:38), so no packet can arrive before we are in place. + */ +export function attachReverse(socket: ReverseHostSocketWithReader, openMinirev: OpenMinirev): ReverseSession { + const session = new ReverseSession(socket, openMinirev) + const reader = socket.reader + // Every listener is captured, not just the first: a real Socket has only its + // own bound _handle there, but dropping a listener somebody else installed + // would be a regression we could not see. + const original = reader.listeners('packet') as ((packet: ReversePacket) => void)[] + + reader.removeAllListeners('packet') + reader.on('packet', (packet: ReversePacket) => { + if (isReverseOpen(packet)) { + session.serve(packet) + return + } + for (const listener of original) { + listener(packet) + } + }) + + return session +} + +/** + * What a session needs from adbkit's tcpusb Socket: somewhere to write, and the + * socket's own id counter and service map, which the reverse streams share with + * the client's streams (see ReverseHostSocket in manager.ts). + */ +export type ReverseSessionSocket = ReverseHostSocket + +/** + * The same Socket plus its packet reader, which is what `attachReverse` gets in + * connect.ts. `reader`, `services` and `remoteId` are all declared private in + * socket.d.ts, so the caller needs a cast; TS-private is a compile-time notion + * only and every one of those fields is there at runtime. + */ +export interface ReverseHostSocketWithReader extends ReverseSessionSocket { + reader: { + listeners(event: string): unknown[] + removeAllListeners(event: string): unknown + on(event: string, listener: (packet: ReversePacket) => void): unknown + } +} diff --git a/lib/units/device/plugins/adb-reverse/stream.ts b/lib/units/device/plugins/adb-reverse/stream.ts new file mode 100644 index 0000000000..93189f14ac --- /dev/null +++ b/lib/units/device/plugins/adb-reverse/stream.ts @@ -0,0 +1,214 @@ +/** + * One reverse connection, tunnelled to the user inside the `adb connect` socket. + * + * This is the mirror image of adbkit's tcpusb `Service` (dist/adb/tcpusb/service.js): + * there the user opens a stream on us, here we open a stream on the user. So the + * ids run the other way round — we allocate our own id up front and only learn + * the user's id from their first A_OKAY. + */ + +import {assemble, A_OPEN, A_OKAY, A_WRTE, A_CLSE} from './packet.ts' + +/** The part of adbkit's tcpusb Socket we need; kept narrow so tests can fake it. */ +export interface ReverseSocket { + maxPayload: number + write(chunk: Buffer): boolean +} + +/** An inbound packet, as adbkit's PacketReader hands it to us. */ +export interface ReversePacket { + command: number + arg0: number + arg1: number + data?: Buffer +} + +/** + * The device end of this reverse connection: one multiplexed minirev stream. + * Narrow on purpose — the framing lives in the forward plugin's writer, so all + * this stream needs is somewhere to put the user's bytes. + */ +export interface ReverseDevice { + write(chunk: Buffer): void + /** Hang up the minirev stream; the app on the device sees its socket close. */ + end(): void +} + +export class ReverseStream { + /** The user's id for this stream. Unknown until their first A_OKAY. */ + private remoteId = 0 + /** True once the user has accepted the open, which changes how we close. */ + private opened = false + /** True while an A_WRTE of ours is unacknowledged; blocks the next one. */ + private needAck = true + /** Device bytes waiting for the write window to open. */ + private pending: Buffer[] = [] + /** True once this stream is torn down; nothing more may go on the wire. */ + private ended = false + /** + * True when the device side has closed but we still have bytes in flight or + * pending. Mirrors AOSP's local_socket_closing_list: the stream is not + * destroyed until the last packet drains (sockets.cpp:161, 361-378). + */ + private deviceEnded = false + + constructor( + private socket: ReverseSocket, + private localId: number, + private spec: string, + private device: ReverseDevice, + /** Called once, synchronously, after this stream finishes tearing down. */ + private onEnd?: () => void + ) {} + + /** + * Ask the user's adb server to connect to `spec` on their machine. + * + * arg1 is 0 because we do not know their stream id yet; a non-zero value + * there would be read as a delayed-ack window that was never negotiated. + * The service name is null-terminated, as adbd sends it. + */ + open(): void { + this.socket.write(assemble(A_OPEN, this.localId, 0, Buffer.from(`${this.spec}\0`))) + } + + /** + * A packet arriving from the user for this stream. + * + * Their first A_OKAY is what tells us the id they gave this stream; we must + * echo it as arg1 on everything we send afterwards, or their server has no + * socket to route it to. + */ + handle(packet: ReversePacket): void { + // Packets in flight when we closed still arrive. Our localId is dead by + // now and may already name a different stream on their side, so nothing + // more may go out under it; the device connection is gone regardless. + if (this.ended) { + return + } + + if (packet.command === A_OKAY) { + this.remoteId = packet.arg0 + this.opened = true + // Both the open acknowledgement and every later write acknowledgement + // arrive as a bare A_OKAY, so the same packet clears the write window. + this.needAck = false + this.tryPush() + return + } + + if (packet.command === A_CLSE) { + // Their end is gone, so the app on the device must not be left + // holding an open socket that will never answer. The close is + // echoed as well: AOSP runs local_socket_close -> peer->shutdown + // (sockets.cpp:352), which is remote_socket_shutdown and writes an + // A_CLSE back (sockets.cpp:552). It cannot loop, because by then + // their socket is gone and find_local_socket drops the packet. + this.end() + return + } + + if (packet.command === A_WRTE) { + if (packet.data) { + this.device.write(packet.data) + } + // Acknowledge unconditionally and with no payload. The user's server + // stops reading its socket until this lands (sockets.cpp:232), and a + // payload here would either be misread as a delayed-ack credit or + // rejected outright (adb.cpp:568). + this.socket.write(assemble(A_OKAY, this.localId, this.remoteId)) + } + } + + /** + * The app on the device closed its socket — minirev signals this as a + * zero-length frame, which MinirevReader turns into ('packet', connId, null). + * + * If the send window is already clear and the pending queue is empty we can + * close immediately (mirrors AOSP: packet_queue empty → destroy right away, + * sockets.cpp:364). Otherwise we mark deviceEnded and let tryPush drain the + * queue first; it calls end() after the last chunk goes out, exactly as + * local_socket_flush_incoming does when it sees (closing && !fd_full) + * (sockets.cpp:160-163). + */ + fromDeviceEnd(): void { + this.deviceEnded = true + this.tryPush() + } + + /** Bytes read from the device, on their way to the user. */ + fromDevice(chunk: Buffer): void { + this.pending.push(chunk) + this.tryPush() + } + + /** + * Tear down this reverse connection from our side. + * + * The peer id goes in arg1 because the user's server looks the socket up by + * it and ignores the packet outright when it is zero (adb.cpp:594). A zero + * arg0 is the protocol's failed-OPEN form (adb.cpp:597): before their A_OKAY + * there is no stream of ours for them to charge the close to. + */ + end(): void { + if (this.ended) { + return + } + this.ended = true + // Device side first, mirroring adbkit's Service.end (transport.end before + // the A_CLSE): the app must not keep a socket nobody is serving. Unlike + // adbkit we tear down inside the guard — its transport is attached late + // and may need ending on a stream that never opened, whereas ours is a + // constructor argument, so one close per stream is all that is correct. + this.device.end() + this.socket.write(assemble(A_CLSE, this.opened ? this.localId : 0, this.remoteId)) + this.onEnd?.() + } + + /** + * Send at most one A_WRTE. + * + * With delayed ack unnegotiated, exactly one write may be in flight: AOSP's + * sender stops reading its own socket until the ack lands (sockets.cpp:232, + * "acks not deferred, blocking"). So anything the device produces meanwhile + * has to wait here rather than go out as a second unacked packet. + * + * If the device side has already closed (deviceEnded) and the queue is now + * empty after this write, we close immediately — mirroring AOSP's + * local_socket_flush_incoming: `if (s->closing && !fd_full) s->close(s)` + * (sockets.cpp:160-163). The write itself happens before end() so the bytes + * arrive before the A_CLSE on the same TCP stream. + */ + private tryPush(): void { + if (this.ended) { + return + } + if (!this.needAck && this.pending.length) { + this.socket.write(assemble(A_WRTE, this.localId, this.remoteId, this.takeChunk())) + this.needAck = true + } + // Deferred close: the device hung up while bytes were still queued or in + // flight. This check must sit outside the write above, because the queue + // can be empty while an A_WRTE is still unacknowledged — then there is + // nothing left to send and nothing left to wait for. + if (this.deviceEnded && !this.pending.length) { + this.end() + } + } + + /** + * Next queued bytes, capped at the peer's payload limit. + * + * Chunks are forwarded as they came off the device instead of being merged, + * so the common case copies nothing; only an oversized chunk gets split. + */ + private takeChunk(): Buffer { + const head = this.pending[0] + if (head.length <= this.socket.maxPayload) { + this.pending.shift() + return head + } + this.pending[0] = head.subarray(this.socket.maxPayload) + return head.subarray(0, this.socket.maxPayload) + } +} diff --git a/lib/units/device/plugins/airplane.js b/lib/units/device/plugins/airplane.js index 9649f57ec4..fea923ac7e 100644 --- a/lib/units/device/plugins/airplane.js +++ b/lib/units/device/plugins/airplane.js @@ -1,32 +1,31 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import devutil from '../../../util/devutil.js' import {AirplaneSetMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(devutil) .dependency(router) - .dependency(push) - .define(function(options, devutil, router, push) { + .dependency(transport) + .define(function(options, devutil, router, transport) { var log = logger.createLogger('device:plugins:airplane') router.on(AirplaneSetMessage, async function(channel, message) { const reply = wireutil.reply(options.serial) const enabled = message.enabled - log.info('Setting airplane mode to', enabled) + log.info('Setting airplane mode to %s', enabled) try { await devutil.executeShellCommand(`cmd connectivity airplane-mode ${enabled ? 'enable' : 'disable'}`) } catch (err) { - push.send([ + transport.send([ channel, reply.fail(err + '') ]) return } - push.send([ + transport.send([ channel, reply.okay() ]) diff --git a/lib/units/device/plugins/bluetooth.js b/lib/units/device/plugins/bluetooth.js index b47ac65048..a934258033 100644 --- a/lib/units/device/plugins/bluetooth.js +++ b/lib/units/device/plugins/bluetooth.js @@ -1,16 +1,15 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import service from './service.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {BluetoothCleanBondedMessage, BluetoothGetStatusMessage, BluetoothSetEnabledMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(service) .dependency(router) - .dependency(push) - .define(function(options, service, router, push) { + .dependency(transport) + .define(function(options, service, router, transport) { var log = logger.createLogger('device:plugins:bluetooth') router.on(BluetoothSetEnabledMessage, function(channel, message) { var reply = wireutil.reply(options.serial) @@ -18,14 +17,14 @@ export default syrup.serial() service.setBluetoothEnabled(message.enabled) .timeout(30000) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { - log.error('Setting Bluetooth enabled failed', err.stack) - push.send([ + log.error('Setting Bluetooth enabled failed %s', err.message) + transport.send([ channel, reply.fail(err.message) ]) @@ -35,16 +34,15 @@ export default syrup.serial() var reply = wireutil.reply(options.serial) log.info('Getting Bluetooth status') service.getBluetoothStatus() - .timeout(30000) .then(function(enabled) { - push.send([ + transport.send([ channel, reply.okay(enabled ? 'bluetooth_enabled' : 'bluetooth_disabled') ]) }) .catch(function(err) { - log.error('Getting Bluetooth status failed', err.stack) - push.send([ + log.error('Getting Bluetooth status failed %s', err.message) + transport.send([ channel, reply.fail(err.message) ]) @@ -56,14 +54,14 @@ export default syrup.serial() service.cleanupBondedBluetoothDevices() .timeout(30000) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { - log.error('Cleaning Bluetooth bonded devices failed', err.stack) - push.send([ + log.error('Cleaning Bluetooth bonded devices failed %s', err.message) + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/browser.js b/lib/units/device/plugins/browser.js index e1425ee2fe..44e31613d8 100644 --- a/lib/units/device/plugins/browser.js +++ b/lib/units/device/plugins/browser.js @@ -4,7 +4,7 @@ import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import adb from '../support/adb.js' import service from './service.js' import {BrowserClearMessage, BrowserOpenMessage} from '../../../wire/wire.js' @@ -20,10 +20,10 @@ const mapping = (function() { })() export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(adb) .dependency(service) - .define(function(options, router, push, adb, service) { + .define(function(options, router, transport, adb, service) { var log = logger.createLogger('device:plugins:browser') function pkg(component) { return component.split('/', 1)[0] @@ -59,7 +59,7 @@ export default syrup.serial() } function updateBrowsers(data) { log.info('Updating browser list') - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceBrowserMessage(options.serial, data.selected, data.apps.reduce(appReducer, []).sort(function(appA, appB) { return compareIgnoreCase(appA.name, appB.name) @@ -92,7 +92,7 @@ export default syrup.serial() data: message.url }) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) @@ -104,7 +104,7 @@ export default syrup.serial() else { log.error('Failed to open "%s"', message.url, err.stack) } - push.send([ + transport.send([ channel, reply.fail() ]) @@ -115,14 +115,14 @@ export default syrup.serial() var reply = wireutil.reply(options.serial) adb.getDevice(options.serial).clear(pkg(message.browser)) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Failed to clear "%s"', message.browser, err.stack) - push.send([ + transport.send([ channel, reply.fail() ]) diff --git a/lib/units/device/plugins/clipboard.js b/lib/units/device/plugins/clipboard.js index 30d0278673..e540ce67e0 100644 --- a/lib/units/device/plugins/clipboard.js +++ b/lib/units/device/plugins/clipboard.js @@ -1,30 +1,29 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import service from './service.js' import {CopyMessage, PasteMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(service) - .define(function(options, router, push, service) { + .define(function(options, router, transport, service) { var log = logger.createLogger('device:plugins:clipboard') router.on(PasteMessage, function(channel, message) { log.info('Pasting "%s" to clipboard', message.text) var reply = wireutil.reply(options.serial) service.paste(message.text) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Paste failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -35,14 +34,14 @@ export default syrup.serial() var reply = wireutil.reply(options.serial) service.copy() .then(function(content) { - push.send([ + transport.send([ channel, reply.okay(content) ]) }) .catch(function(err) { log.error('Copy failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/connect.ts b/lib/units/device/plugins/connect.ts index 8dd727d575..33398fc67c 100644 --- a/lib/units/device/plugins/connect.ts +++ b/lib/units/device/plugins/connect.ts @@ -1,20 +1,21 @@ import util from 'util' import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import {JoinGroupByAdbFingerprintMessage} from '../../../wire/wire.js' -import wireutil from '../../../wire/util.js' import lifecycle from '../../../util/lifecycle.js' +import * as streamutil from '../../../util/streamutil.js' import adb from '../support/adb.js' import connector, {DEVICE_TYPE} from '../../base-device/support/connector.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import router from '../../base-device/support/router.js' import group from './group.js' import solo from './solo.js' import urlformat from '../../base-device/support/urlformat.js' import identity from './util/identity.js' import data from './util/data.js' +import minirev from '../resources/minirev.js' import type TcpUsbServer from '@u4/adbkit/dist/adb/tcpusb/server.d.ts' import {AdbKeysUpdatedMessage} from '../../../wire/wire.js' +import {attachReverse, type ReverseSession} from './adb-reverse/session.ts' interface Key { fingerprint: string @@ -24,39 +25,57 @@ interface Key { export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) + .dependency(transport) .dependency(group) .dependency(solo) .dependency(urlformat) .dependency(connector) .dependency(identity) .dependency(data) - .define(async(options, adb, router, push, group, solo, urlformat, connector, identity, data) => { + .dependency(minirev) + .define(async(options, adb, router, transport, group, solo, urlformat, connector, identity, data, minirev) => { const log = logger.createLogger('device:plugins:connect') let activeServer: TcpUsbServer | null = null + const activeSessions = new Set() + // Set to true before deliberate minirev kill so the stream-end + // listener doesn't trigger lifecycle.fatal(). + let stoppingMinirev = false + + // Launch the minirev binary on the device and wait until its abstract + // socket is ready to accept connections. Mirrors what the former + // forward plugin did in startService() + awaitServer(). + async function startMinirev(): Promise { + stoppingMinirev = false + log.info('Launching minirev service') + const out = await adb.getDevice(options.serial).shell(['exec', minirev.bin]) + // Don't use lifecycle.share — it would fatal on deliberate stop(). + // Instead watch for unexpected termination only. + streamutil.talk(log, 'Minirev says: "%s"', out) + out.on('end', () => { + if (!stoppingMinirev && !lifecycle.ending) { + log.fatal('Minirev shell ended we shall share its fate') + lifecycle.fatal() + } + }) + out.on('error', (err: Error) => { + if (!stoppingMinirev && !lifecycle.ending) { + log.fatal('Minirev shell error: %s', err?.message || err) + lifecycle.fatal() + } + }) + } - const notify = async(key: Key) => { + async function awaitMinirev(times = 5, delay = 100): Promise { + log.info('Waiting for minirev socket') try { - const currentGroup = await group.get() - push.send([ - solo.channel, - wireutil.pack(JoinGroupByAdbFingerprintMessage, { - serial: options.serial, - fingerprint: key.fingerprint, - comment: key.comment, - currentGroup: currentGroup?.group - }) - ]) - } - catch(e) { - push.send([ - solo.channel, - wireutil.pack(JoinGroupByAdbFingerprintMessage, { - serial: options.serial, - fingerprint: key.fingerprint, - comment: key.comment - }) - ]) + const conn = await adb.getDevice(options.serial).openLocal('localabstract:minirev') + conn.end() + } catch (err: any) { + if (/closed/.test(err.message) && times > 1) { + await new Promise(r => setTimeout(r, delay)) + return awaitMinirev(times - 1, delay * 2) + } + throw err } } @@ -65,39 +84,53 @@ export default syrup.serial() port: options.connectPort, url: urlformat(options.connectUrlPattern, options.connectPort, identity.model, data ? data.name.id : ''), auth: (key: Key): boolean => false, - start: () => new Promise((resolve, reject) => { + start: async() => { log.info('Starting connect plugin') - // If Auth failed - the entire unit device will fall - // TODO: fix - const auth = (key: Key) => new Promise(async(resolve, reject) => { - // TODO: Dangerous, discuss and remove - router.on(AdbKeysUpdatedMessage, () => notify(key)) - await notify(key) - - if (plugin.auth(key)) { - resolve() - return - } - - // Connection rejected by user-defined auth handler - reject('Auth failed') - }) - - activeServer = adb.createTcpUsbBridge(plugin.serial, {auth}) - .on('listening', () => resolve(plugin.url)) - .on('error', reject) - .on('connection', conn => { - // @ts-ignore - log.info('New remote ADB connection from %s', conn.remoteAddress) - conn.on('userActivity', () => group.keepalive()) + await startMinirev() + await awaitMinirev() + + return new Promise((resolve, reject) => { + // If Auth failed - the entire unit device will fall + // TODO: fix + const auth = (key: Key) => new Promise(async(resolve, reject) => { + if (plugin.auth(key)) { + resolve() + return + } + reject('Auth failed') }) - activeServer!.listen(plugin.port) - - log.info(util.format('Listening on port %d', plugin.port)) - resolve(plugin.url) - }), + activeServer = adb.createTcpUsbBridge(plugin.serial, {auth}) + .on('listening', () => resolve(plugin.url)) + .on('error', reject) + .on('connection', conn => { + // @ts-ignore + log.info('New remote ADB connection from %s', conn.remoteAddress) + conn.on('userActivity', () => group.keepalive()) + + const openMinirev = async(devicePort: number) => { + const conn = await adb.getDevice(plugin.serial).openLocal('localabstract:minirev') + const header = Buffer.alloc(4) + header.writeUInt16LE(0, 0) + header.writeUInt16LE(devicePort, 2) + conn.write(header) + return conn as unknown as import('./adb-reverse/session.ts').MinirevStream + } + + const session: ReverseSession = attachReverse(conn as never, openMinirev) + activeSessions.add(session) + + conn.on('end', () => { + session.end() + activeSessions.delete(session) + }) + }) + + activeServer!.listen(plugin.port) + log.info(util.format('Listening on port %d', plugin.port)) + }) + }, stop: async() => { if (!activeServer) { return @@ -119,6 +152,13 @@ export default syrup.serial() await waitServerClose activeServer = null + + // Kill minirev on device so the next session can bind the + // abstract socket without "Address already in use". + stoppingMinirev = true + await minirev.stop().catch((err: any) => { + log.warn('Failed to stop minirev: %s', err?.message || err) + }) }, end: async() => { if (connector.started && activeServer) { @@ -137,15 +177,12 @@ export default syrup.serial() } ) - group.on('autojoin', (id, joined) => - plugin.auth = key => { - if (id === key.fingerprint && joined) { - return true - } - log.error('Device is already in use') - return false + group.on('leave', () => { + for (const session of activeSessions) { + session.end() } - ) + activeSessions.clear() + }) connector.init({ serial: options.serial, diff --git a/lib/units/device/plugins/filesystem.js b/lib/units/device/plugins/filesystem.js index a24964bb5b..dce5e59508 100644 --- a/lib/units/device/plugins/filesystem.js +++ b/lib/units/device/plugins/filesystem.js @@ -1,19 +1,18 @@ import syrup from '@devicefarmer/stf-syrup' import path from 'path' import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import adb from '../support/adb.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import storage from '../../base-device/support/storage.js' import {FileSystemGetMessage, FileSystemListMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) + .dependency(transport) .dependency(storage) - .define(function(options, adb, router, push, storage) { + .define(function(options, adb, router, transport, storage) { var log = logger.createLogger('device:plugins:filesystem') var plugin = Object.create(null) plugin.retrieve = function(file, jwt) { @@ -39,14 +38,14 @@ export default syrup.serial() var reply = wireutil.reply(options.serial) plugin.retrieve(message.file, message.jwt) .then(function(file) { - push.send([ + transport.send([ channel, reply.okay('success', file) ]) }) .catch(function(err) { - log.warn('Unable to retrieve "%s"', message.file, err.stack) - push.send([ + log.warn('Unable to retrieve "%s": %s', message.file, err.message) + transport.send([ channel, reply.fail(err.message) ]) @@ -56,14 +55,14 @@ export default syrup.serial() var reply = wireutil.reply(options.serial) adb.getDevice(options.serial).readdir(message.dir) .then(function(files) { - push.send([ + transport.send([ channel, reply.okay('success', files) ]) }) .catch(function(err) { - log.warn('Unable to list directory "%s"', message.dir, err.stack) - push.send([ + log.warn('Unable to list directory "%s": %s', message.dir, err.message) + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/forward/index.js b/lib/units/device/plugins/forward/index.js deleted file mode 100644 index 181ca1a560..0000000000 --- a/lib/units/device/plugins/forward/index.js +++ /dev/null @@ -1,165 +0,0 @@ -import net from 'net' -import Promise from 'bluebird' -import syrup from '@devicefarmer/stf-syrup' -import _ from 'lodash' -import wire from '../../../../wire/index.js' -import logger from '../../../../util/logger.js' -import lifecycle from '../../../../util/lifecycle.js' -import * as streamutil from '../../../../util/streamutil.js' -import wireutil from '../../../../wire/util.js' -import ForwardManager from './util/manager.js' -import adb from '../../support/adb.js' -import router from '../../../base-device/support/router.js' -import push from '../../../base-device/support/push.js' -import minirev from '../../resources/minirev.js' -import group from '../group.js' -import {ForwardCreateMessage, ForwardRemoveMessage, ForwardTestMessage} from '../../../../wire/wire.js' -export default syrup.serial() - .dependency(adb) - .dependency(router) - .dependency(push) - .dependency(minirev) - .dependency(group) - .define(async function(options, adb, router, push, minirev, group) { - var log = logger.createLogger('device:plugins:forward') - var plugin = Object.create(null) - var manager = new ForwardManager() - function startService() { - log.info('Launching reverse port forwarding service') - return adb.getDevice(options.serial).shell([ - 'exec', - minirev.bin - ]) - .then(function(out) { - lifecycle.share('Forward shell', out) - streamutil.talk(log, 'Forward shell says: "%s"', out) - }) - } - function connectService(times) { - function tryConnect(times, delay) { - return adb.getDevice(options.serial).openLocal('localabstract:minirev') - .catch(function(err) { - if (/closed/.test(err.message) && times > 1) { - return Promise.delay(delay) - .then(function() { - return tryConnect(times - 1, delay * 2) - }) - } - return Promise.reject(err) - }) - } - log.info('Connecting to reverse port forwarding service') - return tryConnect(times, 100) - } - function awaitServer() { - return connectService(5) - .then(function(conn) { - conn.end() - return true - }) - } - plugin.createForward = function(id, forward) { - log.info('Creating reverse port forward "%s" from ":%d" to "%s:%d"', id, forward.devicePort, forward.targetHost, forward.targetPort) - return connectService(1) - .then(function(out) { - var header = Buffer.alloc(4) - header.writeUInt16LE(0, 0) - header.writeUInt16LE(forward.devicePort, 2) - out.write(header) - return manager.add(id, out, forward) - }) - } - plugin.removeForward = function(id) { - log.info('Removing reverse port forward "%s"', id) - manager.remove(id) - return Promise.resolve() - } - plugin.connect = function(options) { - var resolver = Promise.defer() - var conn = net.connect({ - host: options.targetHost, - port: options.targetPort - }) - function connectListener() { - resolver.resolve(conn) - } - function errorListener(err) { - resolver.reject(err) - } - conn.on('connect', connectListener) - conn.on('error', errorListener) - return resolver.promise.finally(function() { - conn.removeListener('connect', connectListener) - conn.removeListener('error', errorListener) - }) - } - plugin.reset = function() { - manager.removeAll(manager) - } - group.on('leave', plugin.reset) - var pushForwards = _.debounce(function() { - push.send([ - wireutil.global, - wireutil.envelope(new wire.ReverseForwardsEvent(options.serial, manager.listAll())) - ]) - }, 200) - manager.on('add', pushForwards) - manager.on('remove', pushForwards) - await startService() - .then(awaitServer) - .then(function() { - router - .on(ForwardTestMessage, function(channel, message) { - var reply = wireutil.reply(options.serial) - plugin.connect(message) - .then(function(conn) { - conn.end() - push.send([ - channel, - reply.okay('success') - ]) - }) - .catch(function() { - push.send([ - channel, - reply.fail('fail_connect') - ]) - }) - }) - .on(ForwardCreateMessage, function(channel, message) { - var reply = wireutil.reply(options.serial) - plugin.createForward(message.id, message) - .then(function() { - push.send([ - channel, - reply.okay('success') - ]) - }) - .catch(function(err) { - log.error('Reverse port forwarding failed', err.stack) - push.send([ - channel, - reply.fail('fail_forward') - ]) - }) - }) - .on(ForwardRemoveMessage, function(channel, message) { - var reply = wireutil.reply(options.serial) - plugin.removeForward(message.id) - .then(function() { - push.send([ - channel, - reply.okay('success') - ]) - }) - .catch(function(err) { - log.error('Reverse port unforwarding failed', err.stack) - push.send([ - channel, - reply.fail('fail') - ]) - }) - }) - }) - return plugin - }) diff --git a/lib/units/device/plugins/forward/util/manager.js b/lib/units/device/plugins/forward/util/manager.js deleted file mode 100644 index 1591724902..0000000000 --- a/lib/units/device/plugins/forward/util/manager.js +++ /dev/null @@ -1,145 +0,0 @@ -import util from 'util' -import events from 'events' -import net from 'net' -import ForwardReader from './reader.js' -import ForwardWriter from './writer.js' -// Handles a single connection -function DestHandler(id, conn, options) { - var dest = net.connect({ - host: options.targetHost, - port: options.targetPort - }) - var writer = dest.pipe(new ForwardWriter(id)) - // We can't just pipe to conn because we don't want to end it - // when the dest closes. Instead we'll send a special packet - // to it (which is handled by the writer). - function maybePipeManually() { - var chunk - while ((chunk = writer.read())) { - if (!conn.write(chunk)) { - break - } - } - } - function readableListener() { - maybePipeManually() - } - function drainListener() { - maybePipeManually() - } - function endListener() { - conn.removeListener('drain', drainListener) - writer.removeListener('readable', readableListener) - this.emit('end') - } - function errorListener() { - writer.end() - } - writer.on('end', endListener.bind(this)) - writer.on('readable', readableListener) - dest.on('error', errorListener) - conn.on('drain', drainListener) - this.end = function() { - dest.end() - } - this.write = function(chunk) { - dest.write(chunk) - } - events.EventEmitter.call(this) -} -util.inherits(DestHandler, events.EventEmitter) -// Handles a single port -function ForwardHandler(conn, options) { - var destHandlersById = Object.create(null) - function endListener() { - this.emit('end') - } - function packetEndListener(id) { - delete destHandlersById[id] - } - function packetListener(id, packet) { - var dest = destHandlersById[id] - if (packet) { - if (!dest) { - // Let's create a new connection - dest = destHandlersById[id] = new DestHandler(id, conn, options) - dest.on('end', packetEndListener.bind(null, id)) - } - dest.write(packet) - } - else { - // It's a simulated fin packet - if (dest) { - dest.end() - } - } - } - function readableListener() { - // No-op but must exist so that we get the 'end' event. - } - conn.pipe(new ForwardReader()) - .on('end', endListener.bind(this)) - .on('packet', packetListener) - .on('readable', readableListener) - this.options = options - this.end = function() { - conn.end() - } - events.EventEmitter.call(this) -} -util.inherits(ForwardHandler, events.EventEmitter) -// Handles multiple ports -function ForwardManager() { - var handlersById = Object.create(null) - this.has = function(id) { - return !!handlersById[id] - } - this.add = function(id, conn, options) { - function endListener() { - delete handlersById[id] - this.emit('remove', id, options) - } - if (this.has(id)) { - this.remove(id) - } - for (const handlerId in handlersById) { - if (handlersById[handlerId].options.devicePort === options.devicePort) { - this.remove(handlerId) - break - } - } - var handler = new ForwardHandler(conn, options) - handler.on('end', endListener.bind(this)) - handlersById[id] = handler - this.emit('add', id, options) - } - this.remove = function(id) { - var handler = handlersById[id] - if (handler) { - handler.end() - } - delete handlersById[id] - this.emit('remove', id) - } - this.removeAll = function(manager) { - Object.keys(handlersById).forEach(function(id) { - handlersById[id].end() - delete handlersById[id] - manager.emit('remove', id) - }) - } - this.listAll = function() { - return Object.keys(handlersById).map(function(id) { - var handler = handlersById[id] - return { - id: id, - devicePort: handler.options.devicePort, - targetHost: handler.options.targetHost, - targetPort: handler.options.targetPort - } - }) - } - events.EventEmitter.call(this) -} -util.inherits(ForwardManager, events.EventEmitter) -export default ForwardManager diff --git a/lib/units/device/plugins/forward/util/reader.js b/lib/units/device/plugins/forward/util/reader.js deleted file mode 100644 index 55898175fc..0000000000 --- a/lib/units/device/plugins/forward/util/reader.js +++ /dev/null @@ -1,49 +0,0 @@ -import util from 'util' -import stream from 'stream' -var HEADER_SIZE = 4 -function ForwardReader() { - stream.Transform.call(this) - this._header = Buffer.alloc(HEADER_SIZE) - this._needLength = -HEADER_SIZE - this._target = 0 -} -util.inherits(ForwardReader, stream.Transform) -ForwardReader.prototype._transform = function(chunk, encoding, done) { - var cursor = 0 - while (cursor < chunk.length) { - var diff = chunk.length - cursor - // Do we need a header? - if (this._needLength < 0) { - // Still missing a header? - if (chunk.length < -this._needLength) { - // Save what we're received so far. - chunk.copy(this._header, HEADER_SIZE + this._needLength, cursor, cursor + -this._needLength) - break - } - // Combine previous and current chunk in case the header was split. - chunk.copy(this._header, HEADER_SIZE + this._needLength, cursor, cursor + -this._needLength) - cursor += -this._needLength - this._target = this._header.readUInt16LE(0) - this._needLength = this._header.readUInt16LE(2) - if (this._needLength === 0) { - // This is a fin packet - this.emit('packet', this._target, null) - this._needLength = -HEADER_SIZE - } - } - // Do we have a full data packet? - else if (diff >= this._needLength) { - this.emit('packet', this._target, chunk.slice(cursor, cursor + this._needLength)) - cursor += this._needLength - this._needLength = -HEADER_SIZE - } - // We have a partial data packet. - else { - this.emit('packet', this._target, chunk.slice(cursor, cursor + diff)) - this._needLength -= diff - cursor += diff - } - } - done() -} -export default ForwardReader diff --git a/lib/units/device/plugins/forward/util/writer.js b/lib/units/device/plugins/forward/util/writer.js deleted file mode 100644 index 3c8bafba33..0000000000 --- a/lib/units/device/plugins/forward/util/writer.js +++ /dev/null @@ -1,31 +0,0 @@ -import util from 'util' -import stream from 'stream' -var HEADER_SIZE = 4 -var MAX_PACKET_SIZE = 0xFFFF -function ForwardWriter(target) { - stream.Transform.call(this) - this._target = target -} -util.inherits(ForwardWriter, stream.Transform) -ForwardWriter.prototype._transform = function(fullChunk, encoding, done) { - var chunk = fullChunk - var header, length - do { - length = Math.min(MAX_PACKET_SIZE, chunk.length) - header = Buffer.alloc(HEADER_SIZE) - header.writeUInt16LE(this._target, 0) - header.writeUInt16LE(length, 2) - this.push(header) - this.push(chunk.slice(0, length)) - chunk = chunk.slice(length) - } while (chunk.length) - done() -} -ForwardWriter.prototype._flush = function(done) { - var header = Buffer.alloc(HEADER_SIZE) - header.writeUInt16LE(this._target, 0) - header.writeUInt16LE(0, 2) - this.push(header) - done() -} -export default ForwardWriter diff --git a/lib/units/device/plugins/group.ts b/lib/units/device/plugins/group.ts index bdc5ef8b9f..34c910f432 100644 --- a/lib/units/device/plugins/group.ts +++ b/lib/units/device/plugins/group.ts @@ -1,28 +1,21 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' -import {DeviceGetIsInOrigin} from '../../../wire/wire.js' -import wireutil from '../../../wire/util.js' import * as grouputil from '../../../util/grouputil.js' import solo from './solo.js' import identity from './util/identity.js' import service from './service.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' -import sub from '../../base-device/support/sub.js' -import channels from '../../base-device/support/channels.js' +import transport from '../../base-device/support/transport.js' import group from '../../base-device/plugins/group.js' -import {runTransactionDev} from '../../../wire/transmanager.js' export default syrup.serial() .dependency(solo) .dependency(identity) .dependency(service) .dependency(router) - .dependency(push) - .dependency(sub) - .dependency(channels) + .dependency(transport) .dependency(group) - .define(async(options, solo, ident, /** @type {any} */ service, router, push, sub, channels, group) => { + .define(async(options, solo, ident, /** @type {any} */ service, router, transport, group) => { const log = logger.createLogger('device:plugins:group') group.setCheckBeforeAction(async(message: any) => @@ -41,47 +34,30 @@ export default syrup.serial() service.pressKey('home') service.thawRotation() - const {isInOrigin} = await runTransactionDev( - wireutil.global, - DeviceGetIsInOrigin, - {serial: options.serial}, - {sub, push, router} - ) as { isInOrigin: boolean } - - if (isInOrigin) { - log.warn('Cleaning device') - await Promise.all([ - service.sendCommand('settings put system screen_brightness_mode 0'), - service.sendCommand('settings put system screen_brightness 0'), - service.setMasterMute(true), - service.sendCommand('input keyevent 223'), // KEYCODE_SLEEP - service.sendCommand('settings put global http_proxy :0'), - service.sendCommand('pm clear com.android.chrome'), - service.sendCommand('pm clear com.chrome.beta'), - service.sendCommand('pm clear com.sec.android.app.sbrowser'), - service.sendCommand('pm uninstall com.vkontakte.android'), - service.sendCommand('pm uninstall com.vk.im'), - service.sendCommand('pm uninstall com.vk.clips'), - service.sendCommand('pm uninstall com.vk.calls'), - service.sendCommand('pm uninstall com.vk.admin'), - service.sendCommand('pm clear com.mi.globalbrowser'), - service.sendCommand('pm clear com.microsoft.emmx'), - service.sendCommand('pm clear com.huawei.browser'), - service.sendCommand('pm uninstall --user 0 com.samsung.clipboardsaveservice'), - service.sendCommand('pm uninstall --user 0 com.samsung.android.clipboarduiservice'), - service.sendCommand('rm -rf /sdcard/Downloads'), - service.sendCommand('rm -rf /storage/emulated/legacy/Downloads'), - service.sendCommand('settings put global always_finish_activities 0'), - service.sendCommand('pm enable-user com.google.android.gms'), - service.sendCommand('settings put system font_scale 1.0'), - service.sendCommand('su'), - service.sendCommand('echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line'), - service.sendCommand('am set-debug-app --persistent com.android.chrome') - ]) - } - else { - log.warn('Device was not cleared because it in custom group') - } + log.warn('Cleaning device') + await Promise.all([ + service.sendCommand('settings put system screen_brightness_mode 0'), + service.sendCommand('settings put system screen_brightness 0'), + service.setMasterMute(true), + service.sendCommand('input keyevent 223'), // KEYCODE_SLEEP + service.sendCommand('settings put global http_proxy :0'), + service.sendCommand('pm clear com.android.chrome'), + service.sendCommand('pm clear com.chrome.beta'), + service.sendCommand('pm clear com.sec.android.app.sbrowser'), + service.sendCommand('pm clear com.mi.globalbrowser'), + service.sendCommand('pm clear com.microsoft.emmx'), + service.sendCommand('pm clear com.huawei.browser'), + service.sendCommand('pm uninstall --user 0 com.samsung.clipboardsaveservice'), + service.sendCommand('pm uninstall --user 0 com.samsung.android.clipboarduiservice'), + service.sendCommand('rm -rf /sdcard/Downloads'), + service.sendCommand('rm -rf /storage/emulated/legacy/Downloads'), + service.sendCommand('settings put global always_finish_activities 0'), + service.sendCommand('pm enable-user com.google.android.gms'), + service.sendCommand('settings put system font_scale 1.0'), + service.sendCommand('su'), + service.sendCommand('echo "chrome --disable-fre --no-default-browser-check --no-first-run" > /data/local/tmp/chrome-command-line'), + service.sendCommand('am set-debug-app --persistent com.android.chrome') + ]) } } catch (err: any) { diff --git a/lib/units/device/plugins/install.ts b/lib/units/device/plugins/install.ts index bd68ca93a2..8b5bb2d1fb 100644 --- a/lib/units/device/plugins/install.ts +++ b/lib/units/device/plugins/install.ts @@ -7,7 +7,7 @@ import * as promiseutil from '../../../util/promiseutil.js' import {Utils} from '@u4/adbkit' import adb from '../support/adb.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import storage from '../../base-device/support/storage.js' import {InstallMessage, InstallResultMessage, UninstallMessage} from '../../../wire/wire.js' @@ -28,9 +28,9 @@ interface Manifest { export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) + .dependency(transport) .dependency(storage) - .define((options: InstallOptions, adb: any, router: any, push: any, storage: any) => { + .define((options: InstallOptions, adb: any, router: any, transport: any, storage: any) => { const log = logger.createLogger('device:plugins:install') const reply = wireutil.reply(options.serial) @@ -45,7 +45,7 @@ export default syrup.serial() const sendProgress = (data: string, progress: number): void => { if (!isApi) { - push.send([ + transport.send([ channel, reply.progress(data, progress) ]) @@ -109,11 +109,11 @@ export default syrup.serial() const result = buffer.toString() log.info('Installing result ' + result) if (result.includes('Success')) { - push.send([ + transport.send([ channel, reply.okay('Installed successfully') ]) - push.send([ + transport.send([ channel, wireutil.pack(InstallResultMessage, { serial: options.serial, @@ -134,12 +134,12 @@ export default syrup.serial() else { log.error('Tried to install package "%s", got "%s"', pkg, result) - push.send([ + transport.send([ channel, reply.fail(result) ]) - push.send([ + transport.send([ channel, wireutil.pack(InstallResultMessage, { serial: options.serial, @@ -205,7 +205,7 @@ export default syrup.serial() // Check for timeout-like errors if (err?.name === 'TimeoutError' || err?.message?.includes('timeout')) { log.error('Installation of package "%s" failed: %s', pkg, err.stack) - push.send([ + transport.send([ channel, reply.fail('INSTALL_ERROR_TIMEOUT') ]) @@ -213,7 +213,7 @@ export default syrup.serial() } log.error('Installation of package "%s" failed: %s', pkg, err) - push.send([ + transport.send([ channel, reply.fail('INSTALL_ERROR_UNKNOWN') ]) @@ -224,14 +224,14 @@ export default syrup.serial() log.info('Uninstalling "%s"', message.packageName) try { await adb.getDevice(options.serial).uninstall(message.packageName) - push.send([ + transport.send([ channel, reply.okay('success') ]) } catch (err: any) { log.error('Uninstallation failed: %s', err) - push.send([ + transport.send([ channel, reply.fail('fail') ]) diff --git a/lib/units/device/plugins/logcat.js b/lib/units/device/plugins/logcat.js deleted file mode 100644 index b88c9f2ab4..0000000000 --- a/lib/units/device/plugins/logcat.js +++ /dev/null @@ -1,125 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import Promise from 'bluebird' -import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' -import wireutil from '../../../wire/util.js' -import lifecycle from '../../../util/lifecycle.js' -import adb from '../support/adb.js' -import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' -import group from './group.js' -import {LogcatApplyFiltersMessage, LogcatStartMessage, LogcatStopMessage} from '../../../wire/wire.js' -export default syrup.serial() - .dependency(adb) - .dependency(router) - .dependency(push) - .dependency(group) - .define(function(options, adb, router, push, group) { - var log = logger.createLogger('device:plugins:logcat') - var plugin = Object.create(null) - var activeLogcat = null - plugin.start = function(filters) { - return group.get() - .then(function(group) { - return plugin.stop() - .then(function() { - log.info('Starting logcat') - return adb.getDevice(options.serial).openLogcat({ - clear: true - }) - }) - .timeout(10000) - .then(function(logcat) { - activeLogcat = logcat - function entryListener(entry) { - push.send([ - group.group, - wireutil.envelope(new wire.DeviceLogcatEntryMessage(options.serial, entry.date.getTime() / 1000, entry.pid, entry.tid, entry.priority, entry.tag, entry.message)) - ]) - } - logcat.on('entry', entryListener) - return plugin.reset(filters) - }) - }) - } - plugin.stop = Promise.method(function() { - if (plugin.isRunning()) { - log.info('Stopping logcat') - activeLogcat.end() - activeLogcat = null - } - }) - plugin.reset = Promise.method(function(filters) { - if (plugin.isRunning()) { - activeLogcat - .resetFilters() - if (filters.length) { - activeLogcat.excludeAll() - filters.forEach(function(filter) { - activeLogcat.include(filter.tag, filter.priority) - }) - } - } - else { - throw new Error('Logcat is not running') - } - }) - plugin.isRunning = function() { - return !!activeLogcat - } - lifecycle.observe(plugin.stop) - group.on('leave', plugin.stop) - router - .on(LogcatStartMessage, function(channel, message) { - var reply = wireutil.reply(options.serial) - plugin.start(message.filters) - .then(function() { - push.send([ - channel, - reply.okay('success') - ]) - }) - .catch(function(err) { - log.error('Unable to open logcat', err.stack) - push.send([ - channel, - reply.fail('fail') - ]) - }) - }) - .on(LogcatApplyFiltersMessage, function(channel, message) { - var reply = wireutil.reply(options.serial) - plugin.reset(message.filters) - .then(function() { - push.send([ - channel, - reply.okay('success') - ]) - }) - .catch(function(err) { - log.error('Failed to apply logcat filters', err.stack) - push.send([ - channel, - reply.fail('fail') - ]) - }) - }) - .on(LogcatStopMessage, function(channel) { - var reply = wireutil.reply(options.serial) - plugin.stop() - .then(function() { - push.send([ - channel, - reply.okay('success') - ]) - }) - .catch(function(err) { - log.error('Failed to stop logcat', err.stack) - push.send([ - channel, - reply.fail('fail') - ]) - }) - }) - return plugin - }) diff --git a/lib/units/device/plugins/logcat.ts b/lib/units/device/plugins/logcat.ts new file mode 100644 index 0000000000..baacb8eed1 --- /dev/null +++ b/lib/units/device/plugins/logcat.ts @@ -0,0 +1,106 @@ +import syrup from '@devicefarmer/stf-syrup' +import logger from '../../../util/logger.js' +import wireutil from '../../../wire/util.js' +import lifecycle from '../../../util/lifecycle.js' +import adb from '../support/adb.js' +import router from '../../base-device/support/router.js' +import transport from '../../base-device/support/transport.js' +import group from './group.js' +import { + DeviceLogcatEntryMessage, + LogcatApplyFiltersMessage, + LogcatStartMessage, + LogcatStopMessage, +} from '../../../wire/wire.js' + +export default syrup.serial() + .dependency(adb) + .dependency(router) + .dependency(transport) + .dependency(group) + .define(function(options: any, adb: any, router: any, transport: any, group: any) { + const log = logger.createLogger('device:plugins:logcat') + + let activeLogcat: any = null + + const isRunning = () => activeLogcat !== null + + function stop(): void { + if (isRunning()) { + log.info('Stopping logcat') + activeLogcat.end() + activeLogcat = null + } + } + + function reset(filters: Array<{tag: string, priority: number}>): void { + if (!isRunning()) throw new Error('Logcat is not running') + activeLogcat.resetFilters() + if (filters.length) { + activeLogcat.excludeAll() + for (const f of filters) { + activeLogcat.include(f.tag, f.priority) + } + } + } + + async function start(filters: Array<{tag: string, priority: number}>): Promise { + await group.get() + stop() + log.info('Starting logcat') + activeLogcat = await adb.getDevice(options.serial).openLogcat({clear: true}) + activeLogcat.on('entry', (entry: any) => { + try { + transport.send([ + group.group, + wireutil.pack(DeviceLogcatEntryMessage, { + serial: options.serial, + date: entry.date.getTime() / 1000, + pid: entry.pid >>> 0, + tid: entry.tid >>> 0, + priority: entry.priority >>> 0, + tag: entry.tag, + message: entry.message, + }), + ]) + } catch (err: any) { + log.warn('Skipping malformed logcat entry: %s', err?.message) + } + }) + reset(filters) + } + + lifecycle.observe(stop) + group.on('leave', stop) + + router + .on(LogcatStartMessage, (channel: string, message: any) => { + const reply = wireutil.reply(options.serial) + start(message.filters) + .then(() => transport.send([channel, reply.okay('success')])) + .catch((err: any) => { + log.error('Unable to open logcat: %s', err?.stack) + transport.send([channel, reply.fail('fail')]) + }) + }) + .on(LogcatApplyFiltersMessage, (channel: string, message: any) => { + const reply = wireutil.reply(options.serial) + try { + reset(message.filters) + transport.send([channel, reply.okay('success')]) + } catch (err: any) { + log.error('Failed to apply logcat filters: %s', err?.stack) + transport.send([channel, reply.fail('fail')]) + } + }) + .on(LogcatStopMessage, (channel: string) => { + const reply = wireutil.reply(options.serial) + try { + stop() + transport.send([channel, reply.okay('success')]) + } catch (err: any) { + log.error('Failed to stop logcat: %s', err?.stack) + transport.send([channel, reply.fail('fail')]) + } + }) + }) diff --git a/lib/units/device/plugins/mobile-service.js b/lib/units/device/plugins/mobile-service.js index 071c763ac3..748bf9d3b8 100644 --- a/lib/units/device/plugins/mobile-service.js +++ b/lib/units/device/plugins/mobile-service.js @@ -2,16 +2,16 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import service from './service.js' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(service) - .define(function(options, push, service) { + .define(function(options, transport, service) { const log = logger.createLogger('device:plugins:mobile-service') function updateMobileServices(data) { log.info('Updating mobile services list') - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.GetServicesAvailabilityMessage(options.serial, data.hasGMS, data.hasHMS)) ]) diff --git a/lib/units/device/plugins/reboot.js b/lib/units/device/plugins/reboot.js index f872fe8a46..cd8f2de329 100644 --- a/lib/units/device/plugins/reboot.js +++ b/lib/units/device/plugins/reboot.js @@ -4,27 +4,27 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import adb from '../support/adb.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {RebootMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) - .define(function(options, adb, router, push) { + .dependency(transport) + .define(function(options, adb, router, transport) { const log = logger.createLogger('device:plugins:reboot') router.on(RebootMessage, function(channel) { let reply = wireutil.reply(options.serial) log.important('Rebooting') adb.getDevice(options.serial).reboot() .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Reboot failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/remotedebug.js b/lib/units/device/plugins/remotedebug.js index 7244a92279..7c5e857eed 100644 --- a/lib/units/device/plugins/remotedebug.js +++ b/lib/units/device/plugins/remotedebug.js @@ -3,14 +3,14 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import logger from '../../../util/logger.js' import group from './group.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' export default syrup.serial() .dependency(group) - .dependency(push) - .define((options, group, push) => { + .dependency(transport) + .define((options, group, transport) => { const log = logger.createLogger('device:plugins:remotedebug') const updateRemoteConnectUrl = (group) => { - push.send([ + transport.send([ group.group, wireutil.envelope(new wire.UpdateRemoteConnectUrl(options.serial)) ]) diff --git a/lib/units/device/plugins/ringer.js b/lib/units/device/plugins/ringer.js index bafbfe9a19..70e7e583dd 100644 --- a/lib/units/device/plugins/ringer.js +++ b/lib/units/device/plugins/ringer.js @@ -4,13 +4,13 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import service from './service.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {RingerGetMessage, RingerSetMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(service) .dependency(router) - .dependency(push) - .define(function(options, service, router, push) { + .dependency(transport) + .define(function(options, service, router, transport) { var log = logger.createLogger('device:plugins:ringer') router.on(RingerSetMessage, function(channel, message) { var reply = wireutil.reply(options.serial) @@ -18,14 +18,14 @@ export default syrup.serial() service.setRingerMode(message.mode) .timeout(30000) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Setting ringer mode failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -37,14 +37,14 @@ export default syrup.serial() service.getRingerMode() .timeout(30000) .then(function(mode) { - push.send([ + transport.send([ channel, reply.okay('success', mode) ]) }) .catch(function(err) { log.error('Getting ringer mode failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/screen/capture.js b/lib/units/device/plugins/screen/capture.js index 5d689d2be5..28580311f1 100644 --- a/lib/units/device/plugins/screen/capture.js +++ b/lib/units/device/plugins/screen/capture.js @@ -2,11 +2,10 @@ import util from 'util' import syrup from '@devicefarmer/stf-syrup' import {Adb} from '@u4/adbkit' import logger from '../../../../util/logger.js' -import wire from '../../../../wire/index.js' import wireutil from '../../../../wire/util.js' import adb from '../../support/adb.js' import router from '../../../base-device/support/router.js' -import push from '../../../base-device/support/push.js' +import transport from '../../../base-device/support/transport.js' import storage from '../../../base-device/support/storage.js' import minicap from '../../resources/minicap.js' import display from '../util/display.js' @@ -14,11 +13,11 @@ import {ScreenCaptureMessage} from '../../../../wire/wire.js' export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) + .dependency(transport) .dependency(storage) .dependency(minicap) .dependency(display) - .define(function(options, adb, router, push, storage, minicap, display) { + .define(function(options, adb, router, transport, storage, minicap, display) { var log = logger.createLogger('device:plugins:screen:capture') var plugin = Object.create(null) function projectionFormat() { @@ -53,14 +52,14 @@ export default syrup.serial() var reply = wireutil.reply(options.serial) plugin.capture() .then(function(file) { - push.send([ + transport.send([ channel, reply.okay('success', file) ]) }) .catch(function(err) { log.error('Screen capture failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/screen/stream.js b/lib/units/device/plugins/screen/stream.js index 5305c28868..b211c74651 100644 --- a/lib/units/device/plugins/screen/stream.js +++ b/lib/units/device/plugins/screen/stream.js @@ -373,7 +373,7 @@ export default syrup.serial() } FrameProducer.prototype._readBanner = function(socket) { log.info('Reading minicap banner') - return bannerutil.read(socket).timeout(2000) + return bannerutil.read(socket, AbortSignal.timeout(2000)) } FrameProducer.prototype._readFrames = function(socket) { this.needsReadable = true diff --git a/lib/units/device/plugins/screen/util/banner.js b/lib/units/device/plugins/screen/util/banner.js deleted file mode 100644 index b89729bb57..0000000000 --- a/lib/units/device/plugins/screen/util/banner.js +++ /dev/null @@ -1,104 +0,0 @@ -import Promise from 'bluebird' -export const read = function parseBanner(out) { - var tryRead - return new Promise(function(resolve, reject) { - var readBannerBytes = 0 - var needBannerBytes = 2 - var banner = out.banner = { - version: 0, - length: 0, - pid: 0, - realWidth: 0, - realHeight: 0, - virtualWidth: 0, - virtualHeight: 0, - orientation: 0, - quirks: { - dumb: false, - alwaysUpright: false, - tear: false - } - } - tryRead = function() { - for (var chunk; (chunk = out.read(needBannerBytes - readBannerBytes));) { - for (var cursor = 0, len = chunk.length; cursor < len;) { - if (readBannerBytes < needBannerBytes) { - switch (readBannerBytes) { - case 0: - // version - banner.version = chunk[cursor] - break - case 1: - // length - banner.length = needBannerBytes = chunk[cursor] - break - case 2: - case 3: - case 4: - case 5: - // pid - banner.pid += - (chunk[cursor] << ((readBannerBytes - 2) * 8)) >>> 0 - break - case 6: - case 7: - case 8: - case 9: - // real width - banner.realWidth += - (chunk[cursor] << ((readBannerBytes - 6) * 8)) >>> 0 - break - case 10: - case 11: - case 12: - case 13: - // real height - banner.realHeight += - (chunk[cursor] << ((readBannerBytes - 10) * 8)) >>> 0 - break - case 14: - case 15: - case 16: - case 17: - // virtual width - banner.virtualWidth += - (chunk[cursor] << ((readBannerBytes - 14) * 8)) >>> 0 - break - case 18: - case 19: - case 20: - case 21: - // virtual height - banner.virtualHeight += - (chunk[cursor] << ((readBannerBytes - 18) * 8)) >>> 0 - break - case 22: - // orientation - banner.orientation += chunk[cursor] * 90 - break - case 23: - // quirks - banner.quirks.dumb = (chunk[cursor] & 1) === 1 - banner.quirks.alwaysUpright = (chunk[cursor] & 2) === 2 - banner.quirks.tear = (chunk[cursor] & 4) === 4 - break - } - cursor += 1 - readBannerBytes += 1 - if (readBannerBytes === needBannerBytes) { - return resolve(banner) - } - } - else { - reject(new Error('Supposedly impossible error parsing banner')) - } - } - } - } - tryRead() - out.on('readable', tryRead) - }) - .finally(function() { - out.removeListener('readable', tryRead) - }) -} diff --git a/lib/units/device/plugins/screen/util/banner.ts b/lib/units/device/plugins/screen/util/banner.ts new file mode 100644 index 0000000000..e97cbd401b --- /dev/null +++ b/lib/units/device/plugins/screen/util/banner.ts @@ -0,0 +1,141 @@ +import type {Readable} from 'node:stream' + +/** + * See https://github.com/openstf/minicap + */ +export interface Banner { + version: number + length: number + pid: number + realWidth: number + realHeight: number + virtualWidth: number + virtualHeight: number + orientation: number + quirks: { + dumb: boolean + alwaysUpright: boolean + tear: boolean + } +} + +type BannerStream = Readable & {banner?: Banner; read: (n: number) => Buffer | null} + +export const read = function parseBanner(out: BannerStream, signal?: AbortSignal): Promise { + return new Promise(function(resolve, reject) { + let readBannerBytes = 0 + let needBannerBytes = 2 + const banner: Banner = (out.banner = { + version: 0, + length: 0, + pid: 0, + realWidth: 0, + realHeight: 0, + virtualWidth: 0, + virtualHeight: 0, + orientation: 0, + quirks: { + dumb: false, + alwaysUpright: false, + tear: false, + }, + }) + + const cleanup = (): void => { + out.removeListener('readable', tryRead) + if (signal) signal.removeEventListener('abort', onAbort) + } + + const onAbort = (): void => { + cleanup() + reject(signal?.reason ?? new Error('Banner read aborted')) + } + + const tryRead = function(): void { + let chunk: Buffer | null + while ((chunk = out.read(needBannerBytes - readBannerBytes))) { + for (let cursor = 0, len = chunk.length; cursor < len;) { + if (readBannerBytes < needBannerBytes) { + switch (readBannerBytes) { + case 0: + // version + banner.version = chunk[cursor] + break + case 1: + // length + banner.length = needBannerBytes = chunk[cursor] + break + case 2: + case 3: + case 4: + case 5: + // pid + banner.pid += (chunk[cursor] << ((readBannerBytes - 2) * 8)) >>> 0 + break + case 6: + case 7: + case 8: + case 9: + // real width + banner.realWidth += (chunk[cursor] << ((readBannerBytes - 6) * 8)) >>> 0 + break + case 10: + case 11: + case 12: + case 13: + // real height + banner.realHeight += (chunk[cursor] << ((readBannerBytes - 10) * 8)) >>> 0 + break + case 14: + case 15: + case 16: + case 17: + // virtual width + banner.virtualWidth += (chunk[cursor] << ((readBannerBytes - 14) * 8)) >>> 0 + break + case 18: + case 19: + case 20: + case 21: + // virtual height + banner.virtualHeight += (chunk[cursor] << ((readBannerBytes - 18) * 8)) >>> 0 + break + case 22: + // orientation + banner.orientation += chunk[cursor] * 90 + break + case 23: + // quirks + banner.quirks.dumb = (chunk[cursor] & 1) === 1 + banner.quirks.alwaysUpright = (chunk[cursor] & 2) === 2 + banner.quirks.tear = (chunk[cursor] & 4) === 4 + break + } + cursor += 1 + readBannerBytes += 1 + if (readBannerBytes === needBannerBytes) { + cleanup() + resolve(banner) + return + } + } else { + cleanup() + reject(new Error('Supposedly impossible error parsing banner')) + return + } + } + } + } + + if (signal) { + if (signal.aborted) { + reject(signal.reason ?? new Error('Banner read aborted')) + return + } + signal.addEventListener('abort', onAbort, {once: true}) + } + + tryRead() + out.on('readable', tryRead) + }) +} diff --git a/lib/units/device/plugins/sd.js b/lib/units/device/plugins/sd.js index da4aca405c..f5bc169ca4 100644 --- a/lib/units/device/plugins/sd.js +++ b/lib/units/device/plugins/sd.js @@ -4,13 +4,13 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import service from './service.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {SdStatusMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(service) .dependency(router) - .dependency(push) - .define(function(options, service, router, push) { + .dependency(transport) + .define(function(options, service, router, transport) { var log = logger.createLogger('device:plugins:sd') router.on(SdStatusMessage, function(channel, message) { var reply = wireutil.reply(options.serial) @@ -18,14 +18,14 @@ export default syrup.serial() service.getSdStatus(message) .timeout(30000) .then(function(mounted) { - push.send([ + transport.send([ channel, reply.okay(mounted ? 'sd_mounted' : 'sd_unmounted') ]) }) .catch(function(err) { log.error('Getting SD card Status', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/service.ts b/lib/units/device/plugins/service.ts index 4187b9224f..20ce46b757 100644 --- a/lib/units/device/plugins/service.ts +++ b/lib/units/device/plugins/service.ts @@ -10,7 +10,7 @@ import * as ms from '../../../wire/messagestream.js' import lifecycle from '../../../util/lifecycle.js' import adb from '../support/adb.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import sdk from '../support/sdk.js' import service from '../resources/service.js' import {Duplex} from 'node:stream' @@ -78,11 +78,11 @@ class MessageResolver { export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) + .dependency(transport) .dependency(sdk) .dependency(service) .dependency(devutil) - .define(async(options, adb, router, push, sdk, apk, devutil) => { + .define(async(options, adb, router, transport, sdk, apk, devutil) => { const log = logger.createLogger('device:plugins:service') const messageResolver = new MessageResolver() const agent: Service = { @@ -158,7 +158,7 @@ export default syrup.serial() switch (envelope.type) { case apk.wire.MessageType.EVENT_AIRPLANE_MODE: message = apk.wire.AirplaneModeEvent.decode(envelope.message) as any - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.AirplaneModeEvent(options.serial, message.enabled)) ]) @@ -166,7 +166,7 @@ export default syrup.serial() break case apk.wire.MessageType.EVENT_BATTERY: message = apk.wire.BatteryEvent.decode(envelope.message) - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.BatteryEvent(options.serial, message.status, message.health, message.source, message.level, message.scale, message.temp, message.voltage)) ]) @@ -178,7 +178,7 @@ export default syrup.serial() break case apk.wire.MessageType.EVENT_CONNECTIVITY: message = apk.wire.ConnectivityEvent.decode(envelope.message) - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.ConnectivityEvent(options.serial, message.connected, message.type, message.subtype, message.failover, message.roaming)) ]) @@ -186,7 +186,7 @@ export default syrup.serial() break case apk.wire.MessageType.EVENT_PHONE_STATE: message = apk.wire.PhoneStateEvent.decode(envelope.message) - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.PhoneStateEvent(options.serial, message.state, message.manual, message.operator)) ]) @@ -194,7 +194,7 @@ export default syrup.serial() break case apk.wire.MessageType.EVENT_ROTATION: message = apk.wire.RotationEvent.decode(envelope.message) - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.RotationEvent(options.serial, message.rotation)) ]) @@ -236,7 +236,7 @@ export default syrup.serial() copy = () => this.getClipboard() - getDisplay = (id: string) => + getDisplay = (id: number) => runServiceCommand(apk.wire.MessageType.GET_DISPLAY, new apk.wire.GetDisplayRequest(id)) .then((data) => { const response = apk.wire.GetDisplayResponse.decode(data) @@ -373,13 +373,19 @@ export default syrup.serial() if (!response.success) { throw new Error('Unable to get properties') } - const mapped = response.properties.reduce( + + const rawMapped = (response.properties || []).reduce( (acc: any, property: any) => { acc[property.name] = property.value return acc - }, {} + }, + {} ) + const mapped = rawMapped && typeof rawMapped === 'object' + ? rawMapped + : {} + if (mapped.imei) { return mapped } @@ -648,7 +654,7 @@ export default syrup.serial() router .on(PhysicalIdentifyMessage, (channel) => { plugin.identity() - push.send([ + transport.send([ channel, wireutil.reply(options.serial).okay() ]) diff --git a/lib/units/device/plugins/shell.js b/lib/units/device/plugins/shell.js index 4ce1ef79c4..2e93a7d735 100644 --- a/lib/units/device/plugins/shell.js +++ b/lib/units/device/plugins/shell.js @@ -5,15 +5,13 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import adb from '../support/adb.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' -import sub from '../../base-device/support/sub.js' +import transport from '../../base-device/support/transport.js' import {ShellCommandMessage, ShellKeepAliveMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(adb) .dependency(router) - .dependency(push) - .dependency(sub) - .define(function(options, adb, router, push, sub) { + .dependency(transport) + .define(function(options, adb, router, transport) { var log = logger.createLogger('device:plugins:shell') router.on(ShellCommandMessage, async function(channel, message) { var reply = wireutil.reply(options.serial) @@ -31,14 +29,14 @@ export default syrup.serial() function readableListener() { let chunk while ((chunk = stream.read())) { - push.send([ + transport.send([ channel, reply.progress(chunk) ]) } } function endListener() { - push.send([ + transport.send([ channel, reply.okay(null) ]) @@ -52,21 +50,22 @@ export default syrup.serial() stream.on('readable', readableListener) stream.on('end', endListener) stream.on('error', errorListener) - sub.subscribe(channel) + // Keepalives arrive by message TYPE via the router (they are + // addressed to the device, not to this response channel), so no + // channel subscription is needed — routing is by deviceKey now. router.on(ShellKeepAliveMessage, keepAliveListener) timer = setTimeout(forceStop, message.timeout) return resolver.promise.finally(function() { stream.removeListener('readable', readableListener) stream.removeListener('end', endListener) stream.removeListener('error', errorListener) - sub.unsubscribe(channel) router.removeListener(wire.ShellKeepAliveMessage, keepAliveListener) clearTimeout(timer) }) } catch(err) { log.error('Shell command "%s" failed', message.command, err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/plugins/solo.js b/lib/units/device/plugins/solo.js index e151d124e2..2dfbd18c88 100644 --- a/lib/units/device/plugins/solo.js +++ b/lib/units/device/plugins/solo.js @@ -1,20 +1,15 @@ import crypto from 'crypto' import syrup from '@devicefarmer/stf-syrup' -import logger from '../../../util/logger.js' -import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' -import sub from '../../base-device/support/sub.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import router from '../../base-device/support/router.js' import identity from './util/identity.js' -import {DeviceDisplayMessage, DeviceIdentityMessage, DevicePhoneMessage, ProbeMessage} from '../../../wire/wire.js' +import {DeviceDisplayMessage, DeviceIdentityMessage, DevicePhoneMessage, DeviceReadyMessage, ProbeMessage} from '../../../wire/wire.js' export default syrup.serial() - .dependency(sub) - .dependency(push) + .dependency(transport) .dependency(router) .dependency(identity) - .define(function(options, sub, push, router, identity) { - var log = logger.createLogger('device:plugins:solo') + .define(function(options, transport, router, identity) { // The channel should keep the same value between restarts, so that // having the client side up to date all the time is not horribly painful. function makeChannelId() { @@ -23,16 +18,14 @@ export default syrup.serial() return hash.digest('base64') } var channel = makeChannelId() - log.info('Subscribing to permanent channel "%s"', channel) - sub.subscribe(channel) router.on(ProbeMessage, function() { - push.send([ + transport.send([ wireutil.global, wireutil.pack(DeviceIdentityMessage, { serial: options.serial, platform: identity.platform, manufacturer: identity.manufacturer, - operator: identity.operator, + operator: identity.operator || undefined, model: identity.model, version: identity.version, abi: identity.abi, @@ -44,16 +37,16 @@ export default syrup.serial() openGLESVersion: identity.openGLESVersion, marketName: identity.marketName, macAddress: identity.macAddress, - ram: identity.ram + ram: identity.ram + '' }) ]) }) return { channel: channel, poke: function() { - push.send([ + transport.send([ wireutil.global, - wireutil.envelope(new wire.DeviceReadyMessage(options.serial, channel)) + wireutil.pack(DeviceReadyMessage, {serial: options.serial, channel}) ]) } } diff --git a/lib/units/device/plugins/store.js b/lib/units/device/plugins/store.js index 00d1a657eb..781f1f3d7d 100644 --- a/lib/units/device/plugins/store.js +++ b/lib/units/device/plugins/store.js @@ -3,14 +3,14 @@ import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import adb from '../support/adb.js' import {StoreOpenMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(adb) - .define(function(options, router, push, adb) { + .define(function(options, router, transport, adb) { var log = logger.createLogger('device:plugins:store') router.on(StoreOpenMessage, function(channel) { log.info('Opening Play Store') @@ -24,14 +24,14 @@ export default syrup.serial() flags: 0x10600000 }) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Play Store could not be opened', err.stack) - push.send([ + transport.send([ channel, reply.fail() ]) diff --git a/lib/units/device/plugins/touch/index.ts b/lib/units/device/plugins/touch/index.ts index 13e961efa3..435e1733da 100644 --- a/lib/units/device/plugins/touch/index.ts +++ b/lib/units/device/plugins/touch/index.ts @@ -52,6 +52,7 @@ interface Banner { interface MinitouchService { bin: string run: (cmd?: string) => Promise + stop: () => Promise } interface TouchOptions { @@ -151,7 +152,7 @@ class TouchConsumer extends EventEmitter { this.touchUp(point) this.touchCommit() } - + private async startState(): Promise { if (this.desiredState.next() !== STATE_STARTED) { this.ensureStateLock = false @@ -164,19 +165,19 @@ class TouchConsumer extends EventEmitter { const out = await this._startService() this.output = new RiskyStream(out) .on('unexpectedEnd', this._outputEnded.bind(this)) - + this._readOutput(this.output.stream) - + const socket = await this._connectService() this.socket = new RiskyStream(socket) .on('unexpectedEnd', this._socketEnded.bind(this)) - + const banner = await this._readBanner(this.socket.stream) this.banner = banner - + this._readUnexpected(this.socket.stream) this._processWriteQueue() - + this.runningState = STATE_STARTED this.emit('start') } catch (err: any) { @@ -217,7 +218,7 @@ class TouchConsumer extends EventEmitter { log.warn('Will not apply desired state due to too many failures') return } - + // Prevent concurrent execution if (this.ensureStateLock) { return @@ -295,7 +296,7 @@ class TouchConsumer extends EventEmitter { this.splitStream.removeAllListeners('data') this.splitStream.destroy() } - + this.splitStream = out.pipe(split()).on('data', (line: any) => { const trimmed = line.toString().trim() if (trimmed === '') { @@ -352,7 +353,7 @@ class TouchConsumer extends EventEmitter { this.splitStream.destroy() this.splitStream = null } - + this.output = null this.socket = null this.banner = null @@ -361,13 +362,13 @@ class TouchConsumer extends EventEmitter { private async _disconnectService(socket: RiskyStream | null): Promise { log.info('Disconnecting from minitouch service') - + if (!socket || socket.ended) { return true } socket.stream.removeListener('readable', this.readableListener) - + return new Promise((resolve) => { const endListener = () => { socket.removeListener('end', endListener) @@ -376,7 +377,7 @@ class TouchConsumer extends EventEmitter { socket.on('end', endListener) socket.stream.resume() socket.end() - + // Add timeout setTimeout(() => { socket.removeListener('end', endListener) @@ -387,7 +388,7 @@ class TouchConsumer extends EventEmitter { private async _stopService(output: RiskyStream | null): Promise { log.info('Stopping minitouch service') - + if (!output || output.ended) { return true } @@ -402,9 +403,9 @@ class TouchConsumer extends EventEmitter { SIGTERM: -15, SIGKILL: -9 }[signal] - + log.info('Sending %s to minitouch', signal) - + await Promise.race([ Promise.all([ output.waitForEnd(), @@ -413,7 +414,7 @@ class TouchConsumer extends EventEmitter { ]), new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 2000)) ]) - + return true } @@ -441,7 +442,7 @@ class TouchConsumer extends EventEmitter { private async _readBanner(socket: any): Promise { log.info('Reading minitouch banner') - + const parser = new Parser(socket) const banner: Banner = { pid: -1, @@ -521,14 +522,14 @@ class TouchConsumer extends EventEmitter { if (!this.socket?.stream) { return } - + // Handle backpressure const canWrite = this.socket.stream.write(chunk) if (!canWrite) { log.warn('Socket buffer is full, experiencing backpressure') } } - + destroy(): void { // Clean up all resources if (this.splitStream) { @@ -536,16 +537,16 @@ class TouchConsumer extends EventEmitter { this.splitStream.destroy() this.splitStream = null } - + if (this.socket) { this.socket.stream.removeListener('readable', this.readableListener) this.socket.removeAllListeners() } - + if (this.output) { this.output.removeAllListeners() } - + this.failCounter.removeAllListeners() this.removeAllListeners() this.writeQueue = [] @@ -583,7 +584,7 @@ export default syrup.serial() // Use Promise.race with once() for cleaner event handling touchConsumer.start() - + return Promise.race([ new Promise((resolve) => { touchConsumer.once('start', () => resolve(touchConsumer)) diff --git a/lib/units/device/plugins/util/display.js b/lib/units/device/plugins/util/display.js deleted file mode 100644 index 33a86c21d7..0000000000 --- a/lib/units/device/plugins/util/display.js +++ /dev/null @@ -1,63 +0,0 @@ -import util from 'util' -import syrup from '@devicefarmer/stf-syrup' -import EventEmitter from 'eventemitter3' -import logger from '../../../../util/logger.js' -import * as streamutil from '../../../../util/streamutil.js' -import adb from '../../support/adb.js' -import minicap from '../../resources/minicap.js' -import service from '../service.js' -import options from '../screen/options.js' -export default syrup.serial() - .dependency(adb) - .dependency(minicap) - .dependency(service) - .dependency(options) - .define(function(options, adb, minicap, service, screenOptions) { - var log = logger.createLogger('device:plugins:display') - function Display(id, properties) { - this.id = id - this.properties = properties - } - util.inherits(Display, EventEmitter) - Display.prototype.updateRotation = function(newRotation) { - log.info('Rotation changed to %d', newRotation) - this.properties.rotation = newRotation - this.emit('rotationChange', newRotation) - } - function infoFromMinicap(id) { - return minicap.run(util.format('-d %d -i', id)) - .then(streamutil.readAll) - .then(function(out) { - var match - if ((match = /^ERROR: (.*)$/.exec(out))) { - throw new Error(match[1]) - } - try { - return JSON.parse(out) - } - catch (e) { - throw new Error(out.toString()) - } - }) - } - function infoFromService(id) { - return service.getDisplay(id) - } - function readInfo(id) { - log.info('Reading display info') - return infoFromService(id) - .catch(function() { - return infoFromMinicap(id) - }) - .then(function(properties) { - properties.url = screenOptions.publicUrl - return new Display(id, properties) - }) - } - return readInfo(0).then(function(display) { - service.on('rotationChange', function(data) { - display.updateRotation(data.rotation) - }) - return display - }) - }) diff --git a/lib/units/device/plugins/util/display.ts b/lib/units/device/plugins/util/display.ts new file mode 100644 index 0000000000..76bf479558 --- /dev/null +++ b/lib/units/device/plugins/util/display.ts @@ -0,0 +1,168 @@ +import util from 'util' +import {EventEmitter} from 'node:events' +import {type Duplex} from 'node:stream' +import syrup from '@devicefarmer/stf-syrup' +import {type Client as AdbClient} from '@u4/adbkit' + +import logger from '../../../../util/logger.js' +import * as streamutil from '../../../../util/streamutil.js' +import devutil, {type DisplayInfo, type DevUtil} from '../../../../util/devutil.js' +import adb from '../../support/adb.js' +import minicap from '../../resources/minicap.js' +import service from '../service.js' +import options from '../screen/options.js' + +const RETRY_ATTEMPTS = 5 +const RETRY_DELAY_MS = 500 + +export interface DisplayProperties extends DisplayInfo { + url?: string +} + +interface ScreenOptions { + devicePort: number + publicPort: number + publicUrl: string +} + +/** + * Surface of the `resources/minicap` syrup unit consumed here. + * NOTE: `run(mode, cmd)` is the documented two-arg form. The `mode` argument + * is ignored when SDK >= 23 (minicap is invoked via app_process). The single + * argument form below is preserved verbatim from the original `display.js`; + * it relies on minicap.run() tolerating an undefined `cmd` on legacy SDKs. + */ +interface MinicapResource { + bin: string + lib: string + apk: string + run(mode: string, cmd?: string): Promise +} + +interface ServicePluginSurface extends EventEmitter { + getDisplay: (id: number) => Promise +} + +export class Display extends EventEmitter { + public id: number + public properties: DisplayProperties + + constructor(id: number, properties: DisplayProperties) { + super() + this.id = id + this.properties = properties + } + + updateRotation(newRotation: number): void { + this.properties.rotation = newRotation + this.emit('rotationChange', newRotation) + } +} + +export default syrup.serial() + .dependency(adb) + .dependency(minicap) + .dependency(service) + .dependency(options) + .dependency(devutil) + .define(async function( + _options: {serial: string}, + _adb: AdbClient, + minicap: MinicapResource, + service: ServicePluginSurface, + screenOptions: ScreenOptions, + devutil: DevUtil + ): Promise { + const log = logger.createLogger('device:plugins:display') + + function infoFromMinicap(id: number): Promise { + return minicap.run(util.format('-d %d -i', id)) + .then((stream) => streamutil.readAll(stream)) + .then(function(out: Buffer): DisplayProperties { + const text = out.toString() + const errMatch = /^ERROR: (.*)$/.exec(text) + if (errMatch) { + throw new Error(errMatch[1]) + } + try { + return JSON.parse(text) as DisplayProperties + } + catch { + throw new Error(text) + } + }) + } + + function infoFromService(id: number): Promise { + return service.getDisplay(id) + } + + async function readInfoOnce(id: number): Promise { + try { + const info = await devutil.getDisplayInfo(id) + if (info.width > 0 && info.height > 0) { + return info + } + log.warn(util.format('devutil.getDisplayInfo returned zero dimensions for id=%s, falling back', id)) + } + catch (err: any) { + log.warn(util.format('devutil.getDisplayInfo failed for id=%s: %s', id, err?.message)) + } + + try { + return await infoFromService(id) + } + catch { + return await infoFromMinicap(id) + } + } + + async function readInfo(id: number): Promise { + log.info('Reading display info') + + let lastErr: unknown = null + for (let attempt = 1; attempt <= RETRY_ATTEMPTS; attempt++) { + try { + const properties = await readInfoOnce(id) + if (properties.width > 0 && properties.height > 0) { + properties.url = screenOptions.publicUrl + return new Display(id, properties) + } + log.warn(util.format( + 'Display info attempt %d/%d returned zero dimensions, retrying', + attempt, + RETRY_ATTEMPTS + )) + } catch (err: any) { + lastErr = err + log.warn(util.format( + 'Display info attempt %d/%d failed: %s', + attempt, + RETRY_ATTEMPTS, + err?.message + )) + } + if (attempt < RETRY_ATTEMPTS) { + await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)) + } + } + + // Last resort: return whatever the legacy paths give us (even if zero) so + // downstream code keeps its existing behaviour rather than crashing the unit. + const lastErrMessage = lastErr instanceof Error ? lastErr.message : 'none' + log.fatal(util.format( + 'Unable to obtain positive display dimensions after %d attempts. lastErr=%s', + RETRY_ATTEMPTS, + lastErrMessage + )) + const properties = await infoFromService(id).catch(() => infoFromMinicap(id)) + properties.url = screenOptions.publicUrl + return new Display(id, properties) + } + + const display = await readInfo(0) + service.on('rotationChange', (data: {rotation: number}) => { + display.updateRotation(data.rotation) + }) + return display + }) diff --git a/lib/units/device/plugins/util/identity.js b/lib/units/device/plugins/util/identity.js deleted file mode 100644 index 562777e4fc..0000000000 --- a/lib/units/device/plugins/util/identity.js +++ /dev/null @@ -1,25 +0,0 @@ -import syrup from '@devicefarmer/stf-syrup' -import devutil from '../../../../util/devutil.js' -import logger from '../../../../util/logger.js' -import display from './display.js' -import phone from './phone.js' -export default syrup.serial() - .dependency(display) - .dependency(phone) - .dependency(devutil) - .define(function(options, display, phone, devutil) { - var log = logger.createLogger('device:plugins:identity') - - async function solve() { - log.info('Solving identity') - let identity = await devutil.makeIdentity() - identity.display = display.properties - identity.phone = phone - if (options.deviceName) { - identity.module = options.deviceName - } - return identity - } - - return solve() - }) diff --git a/lib/units/device/plugins/util/identity.ts b/lib/units/device/plugins/util/identity.ts new file mode 100644 index 0000000000..945587fe16 --- /dev/null +++ b/lib/units/device/plugins/util/identity.ts @@ -0,0 +1,55 @@ +import syrup from '@devicefarmer/stf-syrup' + +import devutil, {type DevUtil, type Identity} from '../../../../util/devutil.js' +import logger from '../../../../util/logger.js' +import display, {type Display, type DisplayProperties} from './display.js' +import phone from './phone.js' + +export interface PhoneIdentity { + imei?: string + imsi?: string + phoneNumber?: string + iccid?: string + network?: string + [key: string]: string | undefined +} + +export interface FullIdentity extends Identity { + display: DisplayProperties + phone: PhoneIdentity + module?: string +} + +interface IdentityOptions { + deviceName?: string + [key: string]: unknown +} + +export default syrup.serial() + .dependency(display) + .dependency(phone) + .dependency(devutil) + .define(function( + options: IdentityOptions, + display: Display, + phone: PhoneIdentity, + devutil: DevUtil + ): Promise { + const log = logger.createLogger('device:plugins:identity') + + async function solve(): Promise { + log.info('Solving identity') + const baseIdentity = await devutil.makeIdentity() + const identity: FullIdentity = { + ...baseIdentity, + display: display.properties, + phone, + } + if (options.deviceName) { + identity.module = options.deviceName + } + return identity + } + + return solve() + }) diff --git a/lib/units/device/plugins/vnc/index.js b/lib/units/device/plugins/vnc/index.js index f980a369df..c118ac794d 100644 --- a/lib/units/device/plugins/vnc/index.js +++ b/lib/units/device/plugins/vnc/index.js @@ -14,7 +14,7 @@ import VncServer from './util/server.js' import VncConnection from './util/connection.js' import PointerTranslator from './util/pointertranslator.js' import router from '../../../base-device/support/router.js' -import push from '../../../base-device/support/push.js' +import transport from '../../../base-device/support/transport.js' import stream from '../screen/stream.js' import touch from '../touch/index.js' import group from '../group.js' @@ -22,12 +22,12 @@ import solo from '../solo.js' import {VncAuthResponsesUpdatedMessage} from '../../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(stream) .dependency(touch) .dependency(group) .dependency(solo) - .define(function(options, router, push, screenStream, touch, group, solo) { + .define(function(options, router, transport, screenStream, touch, group, solo) { var log = logger.createLogger('device:plugins:vnc') function vncAuthHandler(data) { log.info('VNC authentication attempt using "%s"', data.response.toString('hex')) @@ -35,13 +35,13 @@ export default syrup.serial() function notify() { group.get() .then(function(currentGroup) { - push.send([ + transport.send([ solo.channel, wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(options.serial, data.response.toString('hex'), currentGroup.group)) ]) }) .catch(grouputil.NoGroupError, function() { - push.send([ + transport.send([ solo.channel, wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(options.serial, data.response.toString('hex'))) ]) diff --git a/lib/units/device/plugins/wifi.js b/lib/units/device/plugins/wifi.js index 8aace2359f..9e72cab97e 100644 --- a/lib/units/device/plugins/wifi.js +++ b/lib/units/device/plugins/wifi.js @@ -4,13 +4,13 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import service from './service.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {WifiGetStatusMessage, WifiSetEnabledMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(service) .dependency(router) - .dependency(push) - .define(function(options, service, router, push) { + .dependency(transport) + .define(function(options, service, router, transport) { var log = logger.createLogger('device:plugins:wifi') router.on(WifiSetEnabledMessage, function(channel, message) { var reply = wireutil.reply(options.serial) @@ -18,14 +18,14 @@ export default syrup.serial() service.setWifiEnabled(message.enabled) .timeout(30000) .then(function() { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(function(err) { log.error('Setting Wifi enabled failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -37,14 +37,14 @@ export default syrup.serial() service.getWifiStatus() .timeout(30000) .then(function(enabled) { - push.send([ + transport.send([ channel, reply.okay(enabled ? 'wifi_enabled' : 'wifi_disabled') ]) }) .catch(function(err) { log.error('Getting Wifi status failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/device/resources/minirev.js b/lib/units/device/resources/minirev.js index 391dba0839..cd9d402c79 100644 --- a/lib/units/device/resources/minirev.js +++ b/lib/units/device/resources/minirev.js @@ -66,7 +66,8 @@ export default syrup.serial() .then(installAll) .then(function() { return { - bin: resources.bin.dest + bin: resources.bin.dest, + stop: stop } }) }) diff --git a/lib/units/device/resources/minitouch.ts b/lib/units/device/resources/minitouch.ts index dfcac6a89d..ec02614f6c 100644 --- a/lib/units/device/resources/minitouch.ts +++ b/lib/units/device/resources/minitouch.ts @@ -21,6 +21,7 @@ interface MinitouchResource { interface MinitouchResult { bin: string run: (cmd?: string) => Promise + stop: () => Promise } export default syrup.serial() @@ -87,7 +88,17 @@ export default syrup.serial() if (!pid?.length) return; log.info('Stopping minitouch process %s', pid) - return adb.getDevice(options.serial).execOut(['kill', '-9', pid]) + await adb.getDevice(options.serial).execOut(['kill', '-9', pid]) + + // Wait until localabstract:minitouch is actually released. + // STFService's MinitouchAgent may hold the socket after the process + // is gone. ensureUnusedLocalSocket returns the socket name once free. + const deadline = Date.now() + 5000 + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)) + const free = await devutil.ensureUnusedLocalSocket('localabstract:minitouch') + if (free) break + } } } diff --git a/lib/units/groups-engine/index.js b/lib/units/groups-engine/index.js index 7ccb596dd6..d38d783035 100644 --- a/lib/units/groups-engine/index.js +++ b/lib/units/groups-engine/index.js @@ -1,22 +1,52 @@ import devicesWatcher from './watchers/devices.js' import lifecycle from '../../util/lifecycle.js' import logger from '../../util/logger.js' +import srv from '../../util/srv.js' import db from '../../db/index.js' +import {DealerSocket} from '../../util/zmqsocket.js' +import {AppTransport} from '../../wire/app-transport.js' +import {TransactionManager} from '../../wire/transmanager.js' export default (async function(options) { const log = logger.createLogger('groups-engine') - const { - push - , pushdev - , channelRouter - } = await db.createZMQSockets(options.endpoints, log) - await db.connect() + // A single DEALER to the proxy carries the group/user/device change broadcasts + // (as [B, ...]) this unit publishes, and the device Ungroup transactions it + // runs when releasing control. AppTransport wraps it; TransactionManager owns + // request/reply correlation. + const dealer = new DealerSocket({probeRouter: true}) + try { + await Promise.all(options.endpoints.proxy.map((endpoint) => + srv.resolve(endpoint).then((records) => + srv.attempt(records, (record) => { + log.info('Sending to proxy "%s"', record.url) + dealer.connect(record.url) + return Promise.resolve(true) + }) + ) + )) + } + catch (err) { + log.fatal('Unable to connect to proxy endpoint: %s', (err && err.message) || err) + return lifecycle.fatal() + } - devicesWatcher(push, pushdev, channelRouter) + const transport = new AppTransport(dealer) + const txmanager = new TransactionManager(transport) - lifecycle.observe(() => - [push, pushdev].forEach((sock) => sock.close()) - ) + // The group/user DB change handlers publish broadcasts (and run Ungroup + // transactions) over this same transport. + await db.connect({transport, txmanager}) + + devicesWatcher(transport, txmanager) + + lifecycle.observe(() => { + try { + transport.close() + } + catch { + // No-op + } + }) log.info('Groups engine started') }) diff --git a/lib/units/groups-engine/watchers/devices.js b/lib/units/groups-engine/watchers/devices.js index ee6470403f..49b4c057aa 100644 --- a/lib/units/groups-engine/watchers/devices.js +++ b/lib/units/groups-engine/watchers/devices.js @@ -1,4 +1,3 @@ -import {WireRouter} from '../../../wire/router.js' import _ from 'lodash' import util from 'util' import {v4 as uuidv4} from 'uuid' @@ -8,25 +7,17 @@ import wireutil from '../../../wire/util.js' import wire from '../../../wire/index.js' import dbapi from '../../../db/api.js' import db from '../../../db/index.js' -import {LeaveGroupMessage} from '../../../wire/wire.js' -export default (function(push, pushdev, channelRouter) { +import {UngroupMessage} from '../../../wire/wire.js' +/** + * @param {import('../../../wire/app-transport.js').AppTransport} transport + * @param {import('../../../wire/transmanager.js').TransactionManager} txmanager + */ +export default (function(transport, txmanager) { const log = logger.createLogger('watcher-devices') - function sendReleaseDeviceControl(serial, channel) { - push.send([ - channel, - wireutil.envelope(new wire.UngroupMessage(wireutil.toDeviceRequirements({ - serial: { - value: serial, - match: 'exact' - } - }))) - ]) - } function sendDeviceGroupChange(id, group, serial, originName) { - pushdev.send([ - wireutil.global, + transport.sendBroadcast( wireutil.envelope(new wire.DeviceGroupChangeMessage(id, new wire.DeviceGroupMessage(group.id, group.name, new wire.DeviceGroupOwnerMessage(group.owner.email, group.owner.name), new wire.DeviceGroupLifetimeMessage(group.dates[0].start?.getTime(), group.dates[0].stop?.getTime()), group.class, group.repetitions, originName), serial)) - ]) + ) } function sendDeviceChange(device1, device2, action) { function publishDevice() { @@ -37,29 +28,32 @@ export default (function(push, pushdev, channelRouter) { delete device.group.lifeTime return device } - push.send([ - wireutil.global, + transport.sendBroadcast( wireutil.envelope(new wire.DeviceChangeMessage(publishDevice(), action, device2.group.origin, timeutil.now('nano'))) - ]) + ) } - function sendReleaseDeviceControlAndDeviceGroupChange(device, sendDeviceGroupChangeWrapper) { - let messageListener - const responseTimer = setTimeout(function() { - channelRouter.removeListener(wireutil.global, messageListener) - sendDeviceGroupChangeWrapper() - }, 5000) - messageListener = new WireRouter() - .on(LeaveGroupMessage, function(channel, message) { - if (message.serial === device.serial && - message.owner.email === device.owner.email) { - clearTimeout(responseTimer) - channelRouter.removeListener(wireutil.global, messageListener) - sendDeviceGroupChangeWrapper() - } - }) - .handler() - channelRouter.on(wireutil.global, messageListener) - sendReleaseDeviceControl(device.serial, device.channel) + async function sendReleaseDeviceControlAndDeviceGroupChange(device, sendDeviceGroupChangeWrapper) { + // Address the device by (provider.name, serial); the Ungroup transaction + // resolving is the release confirmation. + if (device?.provider?.name) { + try { + await txmanager.runTransaction(device.provider.name, device.serial, UngroupMessage, { + requirements: wireutil.toDeviceRequirements({ + serial: { + value: device.serial, + match: 'exact' + } + }) + }, {timeout: 10000}) + } + catch (/** @type {any} */ err) { + log.warn('Ungroup transaction for %s failed: %s', device.serial, err?.message) + } + } + else { + log.warn('Cannot release control of %s: no provider', device?.serial) + } + sendDeviceGroupChangeWrapper() } let changeStream db.connect().then(client => { @@ -169,7 +163,7 @@ export default (function(push, pushdev, channelRouter) { if (group) { if (isDeleted) { if (oldDoc.owner) { - sendReleaseDeviceControlAndDeviceGroupChange(oldDoc, sendDeviceGroupChangeOnDeviceDeletion) + await sendReleaseDeviceControlAndDeviceGroupChange(oldDoc, sendDeviceGroupChangeOnDeviceDeletion) return } sendDeviceGroupChangeOnDeviceDeletion() @@ -185,7 +179,7 @@ export default (function(push, pushdev, channelRouter) { } if (isChangeCurrentGroup) { if (newDoc.owner && group.users.indexOf(newDoc.owner.email) < 0) { - sendReleaseDeviceControlAndDeviceGroupChange(newDoc, sendDeviceGroupChangeOnDeviceCurrentGroupUpdating) + await sendReleaseDeviceControlAndDeviceGroupChange(newDoc, sendDeviceGroupChangeOnDeviceCurrentGroupUpdating) } else { sendDeviceGroupChangeOnDeviceCurrentGroupUpdating() diff --git a/lib/units/ios-device/index.ts b/lib/units/ios-device/index.ts index c21cab6475..e900ee3128 100755 --- a/lib/units/ios-device/index.ts +++ b/lib/units/ios-device/index.ts @@ -1,3 +1,4 @@ +import os from 'os' import syrup from '@devicefarmer/stf-syrup' import logger from '../../util/logger.js' import lifecycle from '../../util/lifecycle.js' @@ -6,8 +7,8 @@ import solo from '../base-device/plugins/solo.js' import info from './plugins/info.js' import wdaClient from './plugins/wda/client.js' import wda from './plugins/wda/index.js' -import push from '../base-device/support/push.js' -import sub from '../base-device/support/sub.js' +import transport from '../base-device/support/transport.js' +import router from '../base-device/support/router.js' import group from '../base-device/plugins/group.js' import storage from '../base-device/support/storage.js' import devicelog from './plugins/devicelog.js' @@ -18,7 +19,7 @@ import clipboard from './plugins/clipboard.js' import remotedebug from './plugins/remotedebug.js' import filesystem from './plugins/filesystem.js' import connect from './plugins/wda/connect.js' -import {DeviceAbsentMessage, DeviceStatus, DeviceStatusMessage} from "../../wire/wire.js" +import {DeviceAbsentMessage, DeviceStatus, DeviceStatusMessage, DeviceIosIntroductionMessage, DeviceRegisteredMessage, ProviderIosMessage} from "../../wire/wire.js" import wireutil from "../../wire/util.js" import {WebDriverAgent} from "appium-webdriveragent" import { openPort } from "./redirect-ports.js" @@ -34,8 +35,7 @@ interface Options { publicIp: string, endpoints: { - sub: string[] - push: string[] + processor: string[] }, groupTimeout: number storageUrl: string @@ -126,13 +126,13 @@ export default (async(options: Options) => { return syrup.serial() .dependency(heartbeat) .dependency(wdaClient) - .dependency(push) + .dependency(transport) .dependency(wda) - .define(async(options, heartbeat, wdaClient, push) => { + .define(async(options, heartbeat, wdaClient, transport) => { const log = logger.createLogger('ios-device') log.info('Preparing device options: %s', JSON.stringify(options)) - push.send([ + transport.send([ wireutil.global, wireutil.pack(DeviceStatusMessage, { serial: options.serial, @@ -141,7 +141,7 @@ export default (async(options: Options) => { ]) const absentDevice = () => - push.send([ + transport.send([ wireutil.global, wireutil.pack(DeviceAbsentMessage, { serial: options.serial }) ]) @@ -166,7 +166,7 @@ export default (async(options: Options) => { .dependency(info) .dependency(connect) .dependency(group) - .dependency(sub) + .dependency(router) .dependency(storage) .dependency(devicelog) .dependency(stream) @@ -175,8 +175,35 @@ export default (async(options: Options) => { .dependency(clipboard) .dependency(remotedebug) .dependency(filesystem) - .define(async(options, solo, info, connect, group) => { + .define(async(options, solo, info, connect, group, router) => { try { + // Self-register over our own DEALER: the introduction + // must come from the device so the processor derives + // presence from the DEALER identity (deviceKey), not + // from the provider (see MIGRATION-PROGRESS §1.8). The + // ios-provider no longer speaks the wire protocol. + const providerName = options.provider ?? os.hostname() + let regListener: ((...args: any[]) => void) | null = null + const waitRegister = Promise.race([ + new Promise(resolve => + router.on(DeviceRegisteredMessage, regListener = (...args: any[]) => resolve(args)) + ), + new Promise(r => setTimeout(r, 15000)) + ]) + transport.send([ + wireutil.global, + wireutil.pack(DeviceIosIntroductionMessage, { + serial: options.serial, + status: wireutil.toDeviceStatus('device'), + provider: ProviderIosMessage.create({ + channel: solo.channel, + name: providerName + }) + }) + ]) + await waitRegister + router.removeListener(DeviceRegisteredMessage, regListener!) + regListener = null // one-time session for init additional device info await new Promise(resolve => { @@ -200,7 +227,7 @@ export default (async(options: Options) => { connect() solo.poke() - push.send([ + transport.send([ wireutil.global, wireutil.pack(DeviceStatusMessage, { serial: options.serial, diff --git a/lib/units/ios-device/plugins/clipboard.ts b/lib/units/ios-device/plugins/clipboard.ts index 6f516168c9..2a5c5cd98c 100755 --- a/lib/units/ios-device/plugins/clipboard.ts +++ b/lib/units/ios-device/plugins/clipboard.ts @@ -1,19 +1,19 @@ import syrup from '@devicefarmer/stf-syrup' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import wdaClient from './wda/client.js' import {CopyMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(wdaClient) - .define((options, router, push, wdaClient) => { + .define((options, router, transport, wdaClient) => { router.on(CopyMessage, async(channel) => { const reply = wireutil.reply(options.serial) const clipboard = await wdaClient.getClipBoard() - push.send([ + transport.send([ channel, reply.okay(clipboard) ]) diff --git a/lib/units/ios-device/plugins/devicelog.js b/lib/units/ios-device/plugins/devicelog.js index 534c2ec923..0497d7292f 100755 --- a/lib/units/ios-device/plugins/devicelog.js +++ b/lib/units/ios-device/plugins/devicelog.js @@ -5,21 +5,19 @@ import {spawn} from 'child_process' import Promise from 'bluebird' import dbapi from '../../../db/api.js' import logger from '../../../util/logger.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import router from '../../base-device/support/router.js' import split from 'split' -import sub from '../../base-device/support/sub.js' import group from '../../base-device/plugins/group.js' import nsyslogParser from 'nsyslog-parser' import db from '../../../db/index.js' import {LogcatStartMessage, LogcatStopMessage, GroupMessage} from '../../../wire/wire.js' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(router) - .dependency(sub) .dependency(group) - .define(async function(options, push, router, sub, group) { + .define(async function(options, transport, router, group) { const log = logger.createLogger('device:plugins:devicelog') await db.connect() @@ -50,7 +48,7 @@ export default syrup.serial() try { const parsed = nsyslogParser(line) const messagestr = parsed.message ?? '' - push.send([ + transport.send([ realGroup.group, wireutil.envelope(new wire.DeviceLogcatEntryMessage( options.serial @@ -108,7 +106,7 @@ export default syrup.serial() try { await dbapi.loadDeviceBySerial(options.serial) deviceLogger.startLogging(channel) - push.send([channel, reply.okay('success')]) + transport.send([channel, reply.okay('success')]) } catch (error) { log.error('Error loading device', error) @@ -118,7 +116,7 @@ export default syrup.serial() .on(LogcatStopMessage, function(channel, data) { const reply = wireutil.reply(options.serial) deviceLogger.killLoggingProcess() - push.send([channel, reply.okay('success')]) + transport.send([channel, reply.okay('success')]) }) .on(GroupMessage, function(channel, data) { deviceLogger.channel = channel diff --git a/lib/units/ios-device/plugins/filesystem.js b/lib/units/ios-device/plugins/filesystem.js index b9fc1b20e2..890b9a350d 100644 --- a/lib/units/ios-device/plugins/filesystem.js +++ b/lib/units/ios-device/plugins/filesystem.js @@ -3,7 +3,7 @@ import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import storage from '../../base-device/support/storage.js' import {execFile} from 'child_process' import path from 'path' @@ -12,9 +12,9 @@ import {v4 as uuidv4} from 'uuid' import {FileSystemGetMessage, FileSystemListMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(storage) - .define(function(options, router, push, storage) { + .define(function(options, router, transport, storage) { const log = logger.createLogger('device:plugins:filesystem') let plugin = Object.create(null) @@ -50,7 +50,7 @@ export default syrup.serial() (error, stdout, stderr) => { if (error) { log.error('Unable to pull file "%s"', file, stderr) - push.send([ + transport.send([ channel, reply.fail('Could not pull file') ]) @@ -62,7 +62,7 @@ export default syrup.serial() contentType: 'application/octet-stream', jwt: jwt }).then((file) => { - push.send([ + transport.send([ channel, reply.okay('success', file) ]) @@ -84,7 +84,7 @@ export default syrup.serial() {name: 'crashes', mtime: '1970-01-01T00:00:00.000Z', atime: null, ctime: null, birthtime: null, mode: 16877, size: 0}, {name: 'media', mtime: '1970-01-01T00:00:00.000Z', atime: null, ctime: null, birthtime: null, mode: 16877, size: 0} ] - push.send([ + transport.send([ channel, reply.okay('success', dirs) ]) @@ -103,7 +103,7 @@ export default syrup.serial() (error, stdout, stderr) => { if (error) { log.warn('Unable to list directory "%s"', message.dir, stderr) - push.send([ + transport.send([ channel, reply.fail(error.message) ]) @@ -123,7 +123,7 @@ export default syrup.serial() }) }) - push.send([ + transport.send([ channel, reply.okay('success', entries) ]) diff --git a/lib/units/ios-device/plugins/info.ts b/lib/units/ios-device/plugins/info.ts index 55a32e5fc5..b36bc1ef62 100644 --- a/lib/units/ios-device/plugins/info.ts +++ b/lib/units/ios-device/plugins/info.ts @@ -2,7 +2,7 @@ import syrup from '@devicefarmer/stf-syrup' import wireutil from '../../../wire/util.js' import logger from '../../../util/logger.js' import _ from 'lodash' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {getModelName} from './util/iosutil.js' import wdaClient from './wda/client.js' import { @@ -17,9 +17,9 @@ import { import {execFileSync} from 'child_process' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(wdaClient) - .define(async(options, push, wdaClient) => { + .define(async(options, transport, wdaClient) => { const log = logger.createLogger('device:info') const deviceInfo = JSON.parse( @@ -38,7 +38,7 @@ export default syrup.serial() const os = options.deviceInfo?.os_version?.split(' ') - push.send([ + transport.send([ wireutil.global, wireutil.pack(InitializeIosDeviceState, { serial: options.serial, @@ -63,7 +63,7 @@ export default syrup.serial() wdaClient.on('session', sdk => { if (!sdk) return - push.send([ + transport.send([ wireutil.global, wireutil.pack(SdkIosVersion, { id: options.serial, @@ -73,7 +73,7 @@ export default syrup.serial() }) wdaClient.on('battery', (batteryState, batteryLevel) => { - push.send([ + transport.send([ wireutil.global, wireutil.pack(BatteryEvent, { serial: options.serial, @@ -90,7 +90,7 @@ export default syrup.serial() wdaClient.on('rotation', (orientation, rotationDegrees) => { if (!rotationDegrees) return - push.send([ + transport.send([ wireutil.global, wireutil.pack(RotationEvent, { serial: options.serial, @@ -100,7 +100,7 @@ export default syrup.serial() }) wdaClient.on('display', (display) => { - push.send([ + transport.send([ wireutil.global, wireutil.pack(SizeIosDevice, { id: options.serial, diff --git a/lib/units/ios-device/plugins/install.js b/lib/units/ios-device/plugins/install.js index a0f9e19338..c63c047d58 100755 --- a/lib/units/ios-device/plugins/install.js +++ b/lib/units/ios-device/plugins/install.js @@ -6,7 +6,7 @@ import Promise from 'bluebird' import {exec} from 'child_process' import * as promiseutil from '../../../util/promiseutil.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import storage from '../../base-device/support/storage.js' import deviceutil from '../../../util/deviceutil.js' import {InstallMessage, UninstallIosMessage} from '../../../wire/wire.js' @@ -43,9 +43,9 @@ const uninstallApp = (udid, bundleId) => { } export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(storage) - .define(function(options, router, push, storage) { + .define(function(options, router, transport, storage) { const log = logger.createLogger('ios-device:plugins:install') const reply = wireutil.reply(options.serial) @@ -54,7 +54,7 @@ export default syrup.serial() const jwt = message.jwt const sendProgress = (data, progress) => - push.send([channel, reply.progress(data, progress)]) + transport.send([channel, reply.progress(data, progress)]) sendProgress('starting', 0) @@ -70,14 +70,22 @@ export default syrup.serial() promiseutil.periodicNotify(installApp(options.serial, path), 250) .progressed(() => { guesstimate = Math.min(95, guesstimate + 1.5 * (95 - guesstimate) / 35) - sendProgress('installing_app', guesstimate) + sendProgress('installing_app', Math.round(guesstimate)) }) .then((result) => { - push.send([channel, reply.okay('INSTALL_SUCCEEDED')]) + transport.send([channel, reply.okay('INSTALL_SUCCEEDED')]) if (message.launch) { - const bundleId = result.match(/.[\w.-]+[\w-]/) + const bundleId = String(result).match(/Installed:?\s+([\w.-]+)/i)?.[1] + if (!bundleId) { + log.warn('Unable to detect bundle id from idb install output: %s', result) + return + } + sendProgress('launching_app', 100) launchApp(options.serial, bundleId) + .catch((err) => { + log.warn('Application launch after install failed: %s', err.message) + }) } }) .catch((err) => { @@ -92,7 +100,7 @@ export default syrup.serial() if (err.stack.includes('Exit Code 1 is not acceptable [0]')) { errorReply = 'INSTALL_ERROR_IDB_INTERNAL_ERROR' } - push.send([channel, reply.fail(errorReply)]) + transport.send([channel, reply.fail(errorReply)]) }) .finally(() => { log.info('Deleting temp file') diff --git a/lib/units/ios-device/plugins/reboot.js b/lib/units/ios-device/plugins/reboot.js index 5c4cd53839..2031b369d4 100755 --- a/lib/units/ios-device/plugins/reboot.js +++ b/lib/units/ios-device/plugins/reboot.js @@ -6,12 +6,12 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import {execFileSync} from 'child_process' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {RebootMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) - .define((options, router, push) => { + .dependency(transport) + .define((options, router, transport) => { const log = logger.createLogger('device:plugins:reboot') router.on(RebootMessage, (channel) => { log.important('Rebooting') @@ -21,14 +21,14 @@ export default syrup.serial() // TODO: Check real state of reboot Promise.delay(1000) .then(() => { - push.send([ + transport.send([ channel, reply.okay() ]) }) .error((err) => { log.error('Reboot failed', err.stack) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/ios-device/plugins/remotedebug.js b/lib/units/ios-device/plugins/remotedebug.js index 04ea506362..9cd7d701e4 100644 --- a/lib/units/ios-device/plugins/remotedebug.js +++ b/lib/units/ios-device/plugins/remotedebug.js @@ -3,14 +3,14 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import logger from '../../../util/logger.js' import group from '../../base-device/plugins/group.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' export default syrup.serial() .dependency(group) - .dependency(push) - .define((options, group, push) => { + .dependency(transport) + .define((options, group, transport) => { const log = logger.createLogger('remote debug') const updateRemoteConnectUrl = (group) => { - push.send([ + transport.send([ group.group, wireutil.envelope(new wire.UpdateRemoteConnectUrl(options.serial)) ]) diff --git a/lib/units/ios-device/plugins/screen/stream.ts b/lib/units/ios-device/plugins/screen/stream.ts index 5485fa1b7b..928f4324a3 100755 --- a/lib/units/ios-device/plugins/screen/stream.ts +++ b/lib/units/ios-device/plugins/screen/stream.ts @@ -8,16 +8,16 @@ import logger from '../../../../util/logger.js' import * as iosutil from '../util/iosutil.js' import solo from '../../../base-device/plugins/solo.js' import wdaClient from '../wda/client.js' -import push from '../../../base-device/support/push.js' +import transport from '../../../base-device/support/transport.js' import group from '../../../base-device/plugins/group.js' import {NoGroupError} from '../../../../util/grouputil.js' import {decode} from '../../../../util/jwtutil.js' export default syrup.serial() .dependency(solo) .dependency(wdaClient) - .dependency(push) + .dependency(transport) .dependency(group) - .define(function(options, solo, WdaClient, push, group) { + .define(function(options, solo, WdaClient, transport, group) { const log = logger.createLogger('device:plugins:screen:stream') const wss = new webSocketServer.Server({port: options.screenPort}) const url = iosutil.getUri(options.wdaHost, options.mjpegPort) diff --git a/lib/units/ios-device/plugins/wda/index.ts b/lib/units/ios-device/plugins/wda/index.ts index f8104987d8..565083e3fd 100755 --- a/lib/units/ios-device/plugins/wda/index.ts +++ b/lib/units/ios-device/plugins/wda/index.ts @@ -3,8 +3,7 @@ import syrup from '@devicefarmer/stf-syrup' import {WireRouter} from '../../../../wire/router.js' import wireutil from '../../../../wire/util.js' import * as iosutil from '../util/iosutil.js' -import push from '../../../base-device/support/push.js' -import sub from '../../../base-device/support/sub.js' +import transport from '../../../base-device/support/transport.js' import wdaClient from './client.js' import {Esp32Touch} from '../touch/esp32touch.js' import { @@ -27,11 +26,10 @@ import {Readable} from 'stream' import storage from '../../../base-device/support/storage.js' export default syrup.serial() - .dependency(push) - .dependency(sub) + .dependency(transport) .dependency(wdaClient) .dependency(storage) - .define((options, push, sub, wdaClient, storage) => { + .define((options, transport, wdaClient, storage) => { const log = logger.createLogger('wda:index') let cursorDevice: Esp32Touch | null = null @@ -42,7 +40,7 @@ export default syrup.serial() cursorDevice.on('paired', () => { cursorIsPaired = true - push.send([ + transport.send([ wireutil.global, wireutil.pack(CapabilitiesMessage, { serial: options.serial, @@ -55,7 +53,7 @@ export default syrup.serial() cursorDevice.on('disconnected', () => { cursorIsPaired = false cursorDevice?.reboot() - push.send([ + transport.send([ wireutil.global, wireutil.pack(CapabilitiesMessage, { serial: options.serial, @@ -144,7 +142,7 @@ export default syrup.serial() const orientation = iosutil.degreesToOrientation(message.rotation) await wdaClient.rotation(orientation) - push.send([ + transport.send([ wireutil.global, wireutil.pack(RotationEvent, { serial: options.serial, @@ -172,14 +170,14 @@ export default syrup.serial() .handler() wdaClient.on('connected', () => { - sub.on('message', router) + transport.on('message', router) }) wdaClient.on('disconnected', () => { - sub.removeListener('message', router) + transport.removeListener('message', router) }) - push.send([ + transport.send([ wireutil.global, wireutil.pack(CapabilitiesMessage, { serial: options.serial, diff --git a/lib/units/ios-provider/IOSObserver.ts b/lib/units/ios-provider/IOSObserver.ts index 7e769ba093..71e06c4537 100644 --- a/lib/units/ios-provider/IOSObserver.ts +++ b/lib/units/ios-provider/IOSObserver.ts @@ -10,6 +10,7 @@ interface IOSSimEvents { } export default class IOSObserver extends EventEmitter { + private readonly APPLE_VENDOR_ID = 1452 private sims = new Set() private usbListenerStarted = false @@ -85,7 +86,12 @@ export default class IOSObserver extends EventEmitter { if (!this.usbListenerStarted) { const currentDevices = usb.listDevices() for (const device of currentDevices) { - if (!device.serialNumber || device.vendorId !== 1452) continue + if (!device.serialNumber + || device.interfaces.length + || device.vendorId !== this.APPLE_VENDOR_ID + ) { + continue + } this.emit('attached', this.formatUDID(device.serialNumber), false) } @@ -94,10 +100,16 @@ export default class IOSObserver extends EventEmitter { return } - if (event.eventType === 'Connected' && event.device?.vendorId === 1452) { - this.emit('attached', this.formatUDID(event.serialNumber), false) - } else { + if (event.eventType === 'Disconnected') { this.emit('detached', this.formatUDID(event.serialNumber), false) + return + } + + if (event.device + && !event.device.interfaces.length + && event.device.vendorId === this.APPLE_VENDOR_ID + ) { + this.emit('attached', this.formatUDID(event.serialNumber), false) } }) @@ -117,4 +129,3 @@ export default class IOSObserver extends EventEmitter { clearTimeout(this.listnerInterval) } } - diff --git a/lib/units/ios-provider/index.ts b/lib/units/ios-provider/index.ts index 6179bbe750..988dfd81bd 100644 --- a/lib/units/ios-provider/index.ts +++ b/lib/units/ios-provider/index.ts @@ -5,25 +5,12 @@ import {Esp32Touch} from '../ios-device/plugins/touch/esp32touch.js' import IOSObserver from './IOSObserver.js' import {ChildProcess} from 'node:child_process' import {ProcessManager, ResourcePool} from '../../util/ProcessManager.js' -import wireutil from '../../wire/util.js' -import { - DeviceRegisteredMessage, - DeviceAbsentMessage, - DeviceStatusMessage, - DeviceIosIntroductionMessage, - ProviderIosMessage, - DeviceStatus -} from '../../wire/wire.js' import srv from '../../util/srv.js' -import * as zmqutil from '../../util/zmqutil.js' -import {WireRouter} from "../../wire/router.js"; // Device-specific context for process management interface DeviceContext { udid: string isSimulator: boolean - register: Promise - resolveRegister?: () => void } interface ResourceType { @@ -58,8 +45,10 @@ interface Options { killTimeout: number publicIp: string endpoints: { - push: string[] - sub: string[] + // processor ROUTER endpoint(s) — resolved once at startup to fail fast + // on misconfiguration. Each forked ios-device connects over its own + // DEALER (identity = deviceKey); the processor derives presence from that. + processor: string[] } fork: (serial: string, opts: { wdaPort: number @@ -78,7 +67,9 @@ export default async (options: Options): Promise => { const STARTUP_TIMEOUT_MS = 10 * 60 * 1000 const BASE_DELAY = 10_000 - let usedEsp32: PortInfo[] = [] + // ESP32 touch devices currently handed to a running ios-device, keyed by + // udid for O(1) release in onCleanup. + const usedEsp32 = new Map() let curEsp32: PortInfo[] = [] // TODO: refactoring needed @@ -107,40 +98,20 @@ export default async (options: Options): Promise => { espTimer = setTimeout(() => espObserver(), 2500) } - const solo = wireutil.makePrivateChannel() - - // Output - const push = zmqutil.socket('push') + // The provider spawns and supervises ios-device processes but opens no ZMQ + // socket itself. Devices self-register over their own DEALER on startup. + // Resolve the processor endpoint once so a bad configuration fails loudly. try { - await Promise.all(options.endpoints.push.map(async(endpoint) => { + await Promise.all(options.endpoints.processor.map(async(endpoint) => { const records = await srv.resolve(endpoint) return srv.attempt(records, (record) => { - log.info('Sending output to "%s"', record.url) - push.connect(record.url) + log.info('iOS devices will connect to processor "%s"', record.url) + return true }) })) } - catch (err) { - log.fatal('Unable to connect to push endpoint: %s', err) - lifecycle.fatal() - } - - // Input - const sub = zmqutil.socket('sub') - try { - await Promise.all(options.endpoints.sub.map(async(endpoint) => { - const records = await srv.resolve(endpoint) - return srv.attempt(records, (record) => { - log.info('Receiving input to "%s"', record.url) - sub.connect(record.url) - }) - })) - - log.info('Subscribing to permanent channel "%s"', solo) - sub.subscribe(solo) - } - catch (err) { - log.fatal('Unable to connect to sub endpoint: %s', err) + catch (err: any) { + log.fatal('Unable to resolve processor endpoint: %s', err?.message || err) lifecycle.fatal() } @@ -157,17 +128,10 @@ export default async (options: Options): Promise => { const processManager = new ProcessManager({ spawn: async(id, context, [resource]) => { log.info('Spawning device process "%s" with ports [%s]', id, Object.values(resource).join(', ')) - push.send([ - wireutil.global, - wireutil.pack(DeviceStatusMessage, { - serial: id, - status: DeviceStatus.PREPARING - }) - ]) - - const esp32ToUse = _.sample(_.differenceBy(curEsp32, usedEsp32, 'path')) + + const esp32ToUse = _.sample(_.differenceBy(curEsp32, [...usedEsp32.values()], 'path')) if (esp32ToUse) { - usedEsp32.push(esp32ToUse) + usedEsp32.set(id, esp32ToUse) log.info(`Using ${esp32ToUse.path} ESP32`) } @@ -183,15 +147,17 @@ export default async (options: Options): Promise => { onError: (id, context, error) => { log.error('iOS Device process "%s" error: %s', id, error.message) }, - onCleanup: (id, context) => { - // Resolve register if pending - context.resolveRegister?.() - - // Tell others the device is gone - push.send([ - wireutil.global, - wireutil.pack(DeviceAbsentMessage, { serial: id }) - ]) + onCleanup: (id) => { + // Release the ESP32 this device held back to the pool. + // onCleanup runs from ProcessManager.stop() on every removal path. + const freed = usedEsp32.get(id) + if (freed) { + usedEsp32.delete(id) + log.info(`Released ESP32 ${freed.path} from device "${id}"`) + } + // The ios-device's own heartbeat stops when its process dies; + // the processor reaps it on timeout. + log.info('iOS device process "%s" cleaned up', id) } }, { killTimeout: options.killTimeout, @@ -201,15 +167,6 @@ export default async (options: Options): Promise => { resourcePool: portPool }) - // Handle device registration messages - sub.on('message', new WireRouter() - .on(DeviceRegisteredMessage, (channel, message: {serial: string}) => { - const process = processManager.get(message.serial) - process?.context?.resolveRegister?.() - }) - .handler() - ) - let statsTimer: NodeJS.Timeout const stats = (twice = true) => { const processStats = processManager.getStats() @@ -251,72 +208,26 @@ export default async (options: Options): Promise => { } } - // Tell others we found a device - const register = (udid: string) => new Promise( - async(resolve, reject) => { - log.info('Registering device "%s"', udid) - - push.send([ - wireutil.global, - wireutil.pack(DeviceIosIntroductionMessage, { - serial: udid, - status: wireutil.toDeviceStatus('device'), - provider: ProviderIosMessage.create({ - channel: solo, - name: options.name - }) - }) - ]) - - process.nextTick(() => { // after creating process context - const managedProcess = processManager.get(udid) - if (!managedProcess) return - - const timeout = setTimeout(() => reject('Register timeout'), BASE_DELAY) - managedProcess.context.resolveRegister = () => { - clearTimeout(timeout) - delete managedProcess?.context?.resolveRegister - resolve() - } - }) - } - ) - - const startDeviceWork = async(udid: string, restart = false, reRegister = false) => { + const startDeviceWork = async(udid: string, restart = false) => { if (!processManager.has(udid)) return stats(false) const managedProcess = processManager.get(udid) if (!managedProcess) return - if (restart || reRegister) { - log.warn('Trying to %s device again, delay 10 sec [%s]', restart ? 'start' : 'register', udid) + if (restart) { + log.warn('Trying to start device again, delay 10 sec [%s]', udid) await new Promise(r => setTimeout(r, BASE_DELAY)) } log.info('Starting work for device "%s"', udid) - if (reRegister) { - managedProcess.context.register = register(udid) - } - - if (!restart) { - try { - // Wait for registration - await managedProcess.context.register - log.info('Device "%s" registered successfully', udid) - } - catch (err: any) { - log.error('Device "%s" registration failed: %s', udid, err?.message) - return startDeviceWork(udid, false, true) - } - } - - // Start the process (this will spawn and wait for ready) + // Start the process (spawn and wait for ready). The ios-device + // self-registers over its own DEALER once it comes up. const started = await processManager.start(udid) if (!started) { log.error('Failed to start device process [%s]', udid) - return startDeviceWork(udid, true, false) + return startDeviceWork(udid, true) } stats() @@ -331,13 +242,8 @@ export default async (options: Options): Promise => { log.info('Connected device "%s" [%s]', udid, isSimulator ? 'simulator' : 'physical') - // Create device context with registration promise - const deviceContext: DeviceContext = { - udid, isSimulator, register: Promise.resolve() - } - // Create managed process - const process = await processManager.create(udid, deviceContext, { + const process = await processManager.create(udid, {udid, isSimulator}, { initialState: 'waiting', resourceCount: 1 }) @@ -347,9 +253,6 @@ export default async (options: Options): Promise => { return } - // Register device immediately, before 'running' state - deviceContext.register = register(udid) - stats() startDeviceWork(udid) } @@ -378,10 +281,6 @@ export default async (options: Options): Promise => { stats(false) - ;[push, sub].forEach((sock) => - sock.close() - ) - // Clean up all processes return processManager.cleanup() }) diff --git a/lib/units/log/mongodb.js b/lib/units/log/mongodb.js deleted file mode 100644 index 33a5c78b09..0000000000 --- a/lib/units/log/mongodb.js +++ /dev/null @@ -1,44 +0,0 @@ -import Promise from 'bluebird' -import logger from '../../util/logger.js' -import wire from '../../wire/index.js' -import {WireRouter} from '../../wire/router.js' -import wireutil from '../../wire/util.js' -import lifecycle from '../../util/lifecycle.js' -import srv from '../../util/srv.js' -import dbapi from '../../db/api.js' -import * as zmqutil from '../../util/zmqutil.js' -import {DeviceLogMessage} from '../../wire/wire.js' -export default (function(options) { - var log = logger.createLogger('log-db') - // Input - var sub = zmqutil.socket('sub') - Promise.map(options.endpoints.sub, function(endpoint) { - return srv.resolve(endpoint).then(function(records) { - return srv.attempt(records, function(record) { - log.info('Receiving input from "%s"', record.url) - sub.connect(record.url) - return Promise.resolve(true) - }) - }) - }); - [wireutil.global].forEach(function(channel) { - log.info('Subscribing to permanent channel "%s"', channel) - sub.subscribe(channel) - }) - sub.on('message', new WireRouter() - .on(DeviceLogMessage, function(channel, message) { - if (message.priority >= options.priority) { - dbapi.saveDeviceLog(message.serial, message) - } - }) - .handler()) - log.info('Listening for %s (or higher) level log messages', logger.LevelLabel[options.priority]) - lifecycle.observe(function() { - try { - sub.close() - } - catch (err) { - // No-op - } - }) -}) diff --git a/lib/units/processor/index.ts b/lib/units/processor/index.ts index 8287cd0a72..1e62e271d9 100644 --- a/lib/units/processor/index.ts +++ b/lib/units/processor/index.ts @@ -1,15 +1,39 @@ +// +// processor: the mediator between the proxy (app side) and the devices. +// +// Transport: +// - a DEALER connected to the proxy (stable routingId = the processor name); +// - a ROUTER bound for devices/providers. +// +// Framing differs per input: +// - from the proxy over the DEALER: [kind, ...] (identity stripped) +// - from a device over the ROUTER: [deviceKeyId, kind..] (identity prepended) +// +// The device DEALER sets ZMQ_ROUTING_ID = deviceKey (providerName\0serial), so +// the ROUTER-added frame 0 identifies the sending device by its deviceKey. That +// identity is the single source of truth for (providerName, serial) — device +// messages such as heartbeats only carry the bare serial. +// +// Pure routing decisions live in ProcessorRouting; presence lives in +// PresenceTracker; this unit is the ZMQ I/O glue plus the ported dbapi handlers. +// import Promise from 'bluebird' +import {MessageType} from '@protobuf-ts/runtime' import logger from '../../util/logger.js' import wire from '../../wire/index.js' -import {WireRouter} from '../../wire/router.js' import wireutil from '../../wire/util.js' import db from '../../db/index.js' import dbapi from '../../db/models/all/index.js' import lifecycle from '../../util/lifecycle.js' import srv from '../../util/srv.js' -import * as zmqutil from '../../util/zmqutil.js' import UserModel from '../../db/models/user/index.js' import DeviceModel from '../../db/models/device/index.js' +import {RouterSocket, DealerSocket} from '../../util/zmqsocket.js' +import {encodeDeviceFrame, encodeAnnounce, encodeBroadcast, encodeHello, deviceKey} from '../../wire/frame.js' +import {Envelope} from '../../wire/wire.js' +import {Any} from '../../wire/google/protobuf/any.js' +import {ProcessorRouting} from './routing.js' +import {PresenceTracker, type PresenceEvent} from './presence.js' import { UserChangeMessage, GroupChangeMessage, @@ -45,25 +69,56 @@ import { PhoneStateEvent, RotationEvent, CapabilitiesMessage, - ReverseForwardsEvent, + UpdateIosDevice, SdkIosVersion, SizeIosDevice, DeviceTypeMessage, DeleteDevice, GetServicesAvailabilityMessage, - DeviceRegisteredMessage, GetPresentDevices, DeviceGetIsInOrigin, GetDeadDevices, DeviceIosIntroductionMessage + DeviceRegisteredMessage, + GetPresentDevices, + DeviceGetIsInOrigin, + GetDeadDevices, + DeviceIosIntroductionMessage, } from '../../wire/wire.js' interface Options { name: string endpoints: { - appDealer: string[] - devDealer: string[] + // proxy DEALER endpoint(s) to connect to (SRV-resolvable). + proxy: string[] + // ROUTER bind address for devices/providers. + deviceRouter: string } + heartbeatTimeout: number publicIp: string } +// wireutil.pack/envelope return a Uint8Array; the ZMQ layer wants Buffer. +const buf = (u: Uint8Array): Buffer => Buffer.from(u) + +const _deviceKeyCache = new Map() + +const parseDeviceKey = (identity: Buffer): {providerName: string; serial: string} => { + const s = identity.toString() + const cached = _deviceKeyCache.get(s) + if (cached) { + return cached + } + const sep = s.indexOf('\u0000') + const parsed = sep < 0 + ? {providerName: '', serial: s} + : {providerName: s.slice(0, sep), serial: s.slice(sep + 1)} + _deviceKeyCache.set(s, parsed) + return parsed +} + +const dropDeviceKey = (identity: Buffer) => { + _deviceKeyCache.delete(identity.toString()) +} + + export default db.ensureConnectivity(async(options: Options) => { const log = logger.createLogger('processor') if (options.name) { @@ -72,255 +127,568 @@ export default db.ensureConnectivity(async(options: Options) => { await db.connect() - // App side - const appDealer = zmqutil.socket('dealer') - await Promise.all(options.endpoints.appDealer.map(async(endpoint: string) => { + const routing = new ProcessorRouting() + const presence = new PresenceTracker(options.heartbeatTimeout) + + // Processor boot time. Used to identify device presence records that predate + // this instance — candidates for the startup reconciliation sweep. + const startedAt = Date.now() + + // deviceKeys seeded by the startup sweep. On expiry, re-verified against + // Mongo before marking absent — another processor may have adopted them. + // Removed on the first real heartbeat, promoting the device to the normal + // TTL path. + const sweptKeys = new Set() + + // ROUTER (bind) — devices and providers connect here. + const deviceRouter = new RouterSocket() + await deviceRouter.bind(options.endpoints.deviceRouter) + log.info('processor device ROUTER listening on %s', options.endpoints.deviceRouter) + + // DEALER (connect) — the single link up to the proxy. + const proxyDealer = new DealerSocket({routingId: options.name, probeRouter: true}) + await Promise.all(options.endpoints.proxy.map(async(endpoint: string) => { try { return await srv.resolve(endpoint).then((records) => srv.attempt(records, (record) => { - log.info('App dealer connected to "%s"', record.url) - appDealer.connect(record.url) + log.info('Proxy dealer connected to "%s"', record.url) + proxyDealer.connect(record.url) return true }) ) } catch (err: any) { - log.fatal('Unable to connect to app dealer endpoint %s', err?.message) + log.fatal('Unable to connect to proxy endpoint %s', err?.message) lifecycle.fatal() } })) - // Device side - const devDealer = zmqutil.socket('dealer') - appDealer.on('message', (channel, data) => { - devDealer.send([channel, data]) + // (Re)announce every known provider up to the proxy. Providers are otherwise + // announced once each, when first learned — NOT on every heartbeat — so this + // bulk re-announce is what a restarted proxy relies on to relearn them. + const announce = () => { + for (const send of routing.announceFrames()) { + proxyDealer.send(send.frames).catch((err: any) => + log.warn('Announce failed: %s', err?.message)) + } + } + + // Identify ourselves to the proxy as a processor, exactly once. This is the + // only way the proxy can tell a processor apart from an app-side unit, so it + // can elect the first processor to connect and hand it the startup INIT. It + // carries no state and is never repeated. + const hello = () => { + proxyDealer.send(encodeHello()).catch((err: any) => + log.warn('Hello to proxy failed: %s', err?.message)) + } + + // Send a protocol message DOWN to a device by its routing identity. + // + // Both processor-originated messages (registration, probe, auto-group, reply + // to a device request) and proxy-forwarded app commands use [D, providerName, + // serial, ...replyPath, envelope]. Processor-originated messages carry an + // EMPTY reply-path (there is no app node waiting for a reply). The + // (providerName, serial) are derived from the routing identity (deviceKey). + const sendToDevice = (identity: Buffer, envelope: Uint8Array) => { + const {providerName, serial} = parseDeviceKey(identity) + const frames = encodeDeviceFrame(providerName, serial, buf(envelope)) + deviceRouter.send([identity, ...frames]).catch((err: any) => + log.warn('Undeliverable to device %s: %s', identity.toString(), err?.message)) + } + + // Forward an event to the app side, wrapped as a broadcast (B). The proxy + // fans it out to the registered broadcast receivers (websocket, log, ...). + const sendToApp = (envelope: Uint8Array) => { + proxyDealer.send(encodeBroadcast(buf(envelope))) + .catch((err: any) => log.warn('Broadcast to app failed: %s', err?.message)) + } + + // Per-request reply helper. wireutil.reply() packs a TransactionDoneMessage + // without a channel; we re-inject the request's correlationId so the + // requester's TransactionManager can match it. Minted fresh per message to + // avoid interleaving seq counters across concurrent requests. + const makeReply = (correlationId?: string): ReturnType => { + const inner = wireutil.reply(correlationId ?? wireutil.global) + const withChannel = (packed: Uint8Array): Uint8Array => { + if (!correlationId) { + return packed + } + const decoded = Envelope.fromBinary(buf(packed)) + return Envelope.toBinary({...decoded, channel: correlationId}) + } + return { + okay: (data, body) => withChannel(inner.okay(data, body)), + fail: (data, body) => withChannel(inner.fail(data, body)), + progress: (data, progress) => withChannel(inner.progress(data, progress)), + } + } + + // Presence changes are app-bound broadcasts carrying the bare serial. + presence.on('present', ({serial}) => { + log.info('Device "%s" is present', serial) + const now = Date.now() + dbapi.setDevicePresent(serial, now) + sendToApp(wireutil.pack(DevicePresentMessage, {serial, presenceChangedAt: now})) }) - const reply = wireutil.reply(wireutil.global) - await Promise.all(options.endpoints.devDealer.map(async(endpoint: string) => { + presence.on('absent', (event) => { + // Evict the parse-cache entry so the key doesn't linger after the device is gone. + _deviceKeyCache.delete(deviceKey(event.providerName, event.serial)) + + // A swept device is one we seeded from Mongo at startup and never heard + // from. Its expiry needs a Mongo re-check, so it is batched separately. + if (sweptKeys.delete(deviceKey(event.providerName, event.serial))) { + enqueueSweptAbsent(event) + return + } + log.info('Reaping device "%s" due to heartbeat timeout', event.serial) + + const now = Date.now() + dbapi.setDeviceAbsent(event.serial, now) + sendToApp(wireutil.pack(DeviceAbsentMessage, {serial: event.serial, presenceChangedAt: now})) + }) + + // ---- startup presence reconciliation ------------------------------------ + // + // Devices left present by a previous processor instance would stay present + // forever — the old processor is gone, so nobody is watching their + // heartbeats. The proxy elects exactly one processor per proxy lifetime to + // clean this up by sending it an INIT with the proxy's start time. + // + // We only act when the proxy is OLDER than us: that means we are a fresh + // generation, so any presence predating our boot belongs to a processor that + // is no longer running. If the proxy started after us, it restarted while we + // were alive, and the presence records are ours — live TTLs already cover them. + let swept = false + + const runStartupSweep = async (proxyStartedAt: number) => { + if (swept) { + return + } + swept = true + + if (proxyStartedAt >= startedAt) { + log.info('Skipping startup presence sweep: proxy (%s) is not older than us (%s)', + proxyStartedAt, startedAt) + return + } + + const stale = await DeviceModel.loadDevicesPresentBefore(new Date(startedAt)) + log.info('Startup presence sweep: watching %s device(s) present before our boot', stale.length) + + for (const device of stale) { + const providerName = device.provider?.name ?? '' + sweptKeys.add(deviceKey(providerName, device.serial)) + // silent: we are NOT claiming these devices, so no 'present' + // broadcast. time = startedAt so they all expire together exactly one + // heartbeatTimeout after our boot — a live device will heartbeat to us + // before then and get promoted off sweptKeys. + presence.bump(providerName, device.serial, startedAt, {silent: true}) + } + } + + // Swept devices all share time = startedAt, so TTLSet drops them in one + // synchronous pass. Batch the expiries to collapse N Mongo round-trips into + // a single re-check query. + let sweptBatch: PresenceEvent[] = [] + let sweptFlush: NodeJS.Immediate | null = null + + const enqueueSweptAbsent = (event: PresenceEvent) => { + sweptBatch.push(event) + sweptFlush ??= setImmediate(() => { + sweptFlush = null + const batch = sweptBatch + sweptBatch = [] + flushSweptAbsent(batch).catch((err: any) => + log.error('Startup presence sweep failed: %s', err?.message || err)) + }) + } + + const flushSweptAbsent = async (batch: PresenceEvent[]) => { + // Re-run the exact same filter: a device is only genuinely absent if its + // presence STILL predates our boot. If another processor adopted it, the + // introduction bumped presenceChangedAt past the cutoff — leave it alone. + const stillStale = await DeviceModel.loadDevicesPresentBefore(new Date(startedAt)) + const stillStaleSerials = new Set(stillStale.map((d: any) => d.serial)) + + for (const {serial} of batch) { + if (!stillStaleSerials.has(serial)) { + log.info('Device "%s" was adopted by another processor, leaving it present', serial) + continue + } + log.info('Marking device "%s" absent: no heartbeat since processor start', serial) + const presenceChangedAt = Date.now() + // Unlike the live reap path, we persist directly here instead of + // relying on the broadcast looping back — we already own this write. + await dbapi.setDeviceAbsent(serial, presenceChangedAt) + sendToApp(wireutil.pack(DeviceAbsentMessage, {serial, presenceChangedAt})) + } + } + + // The dbapi handlers dispatch table. + // + // Handlers are async (they await Mongo) and a single processor serves many + // devices concurrently, so each handler receives an immutable per-message + // context `ctx` instead of reading shared mutable state: + // ctx.sendToDevice(envelope) — reply to THIS message's sender; + // ctx.data — the raw inbound Envelope, for verbatim + // forwarding to the app side. + // The dispatch table is built once; ctx is the only per-message value. + interface DeviceContext { + sendToDevice: (envelope: Uint8Array) => void + data: Buffer + // A reply sequencer scoped to THIS request, bound to the request's own + // correlationId (Envelope.channel) so the requester's TransactionManager + // can match it, and to the request's serial as the source. Never share a + // sequencer across messages — concurrent device requests would interleave + // their seq counters and mis-address one another. + reply: ReturnType + } + type DeviceHandler = (ctx: DeviceContext, message: T) => void | PromiseLike + + // Handlers that only forward the inbound Envelope verbatim to the app side. + const forwardToApp: DeviceHandler = (ctx) => sendToApp(ctx.data) + + // Register a handler keyed by its protobuf type url, plus its MessageType so + // we can decode the payload once per message. + const handlers = new Map; handle: DeviceHandler}>() + const on = (type: MessageType, handle: DeviceHandler) => { + handlers.set(Any.typeNameToUrl(type.typeName), {type, handle}) + } + + on(UserChangeMessage, forwardToApp) + on(GroupChangeMessage, forwardToApp) + on(DeviceGroupChangeMessage, forwardToApp) + on(GroupUserChangeMessage, forwardToApp) + on(DeviceLogMessage, forwardToApp) + on(TransactionProgressMessage, forwardToApp) + on(TransactionDoneMessage, forwardToApp) + on(TransactionTreeMessage, forwardToApp) + on(InstallResultMessage, forwardToApp) + on(DeviceLogcatEntryMessage, forwardToApp) + on(TemporarilyUnavailableMessage, forwardToApp) + on(UpdateRemoteConnectUrl, forwardToApp) + on(InstalledApplications, forwardToApp) + on(DeviceHeartbeatMessage, () => { + // presence bump is handled up front from the routing identity. + }) + on(DeviceIntroductionMessage, async (ctx, message) => { + await dbapi.saveDeviceInitialState(message.serial, message) + ctx.sendToDevice(wireutil.pack(DeviceRegisteredMessage, {serial: message.serial})) + sendToApp(ctx.data) + }) + on(DeviceIosIntroductionMessage, async (ctx, message) => { + await dbapi.saveIosDeviceInitialState(options.publicIp, message) + ctx.sendToDevice(wireutil.pack(DeviceRegisteredMessage, {serial: message.serial})) + }) + on(InitializeIosDeviceState, (ctx, message) => { + dbapi.initializeIosDeviceState(options.publicIp, message) + }) + on(DevicePresentMessage, async (ctx, message) => { + await dbapi.setDevicePresent(message.serial, message.presenceChangedAt) + sendToApp(ctx.data) + }) + on(DeviceAbsentMessage, async (ctx, message) => { + await dbapi.setDeviceAbsent(message.serial, message.presenceChangedAt) + sendToApp(ctx.data) + }) + on(DeviceStatusMessage, (ctx, message) => { + dbapi.saveDeviceStatus(message.serial, message.status) + sendToApp(ctx.data) + }) + on(DeviceReadyMessage, async (ctx, message) => { + await dbapi.setDeviceReady(message.serial, message.channel) + ctx.sendToDevice(wireutil.envelope(new wire.ProbeMessage())) + sendToApp(ctx.data) + }) + on(JoinGroupByAdbFingerprintMessage, async (ctx, message) => { try { - return await srv.resolve(endpoint).then((records) => - srv.attempt(records, (record) => { - log.info('Device dealer connected to "%s"', record.url) - devDealer.connect(record.url) - return true - }) + const user = await UserModel.lookupUserByAdbFingerprint(message.fingerprint) + if (user) { + ctx.sendToDevice(wireutil.envelope( + new wire.AutoGroupMessage( + new wire.OwnerMessage(user.email, user.name, user.group), + message.fingerprint))) + return + } + sendToApp(wireutil.pack(JoinGroupByAdbFingerprintMessage, message)) + } catch (err: any) { + log.error('Unable to lookup user by ADB fingerprint "%s": %s', message.fingerprint, err?.message) + } + }) + on(JoinGroupByVncAuthResponseMessage, async (ctx, message) => { + try { + const user = await dbapi.lookupUserByVncAuthResponse(message.response, message.serial) + if (user) { + ctx.sendToDevice(wireutil.envelope( + new wire.AutoGroupMessage( + new wire.OwnerMessage(user.email, user.name, user.group), + message.response))) + return + } + sendToApp(wireutil.pack(JoinGroupByVncAuthResponseMessage, message)) + } catch (err: any) { + log.error('Unable to lookup user by VNC auth response "%s": %s', message.response, err?.message) + } + }) + on(ConnectStartedMessage, async (ctx, message) => { + await dbapi.setDeviceConnectUrl(message.serial, message.url) + sendToApp(ctx.data) + }) + on(ConnectStoppedMessage, async (ctx, message) => { + await dbapi.unsetDeviceConnectUrl(message.serial) + sendToApp(ctx.data) + }) + on(JoinGroupMessage, async (ctx, message) => { + await Promise.all([ // @ts-ignore + dbapi.setDeviceState(message.serial, message), + dbapi.sendEvent(`device_${message.usage || 'use'}` + , {} + , {deviceSerial: message.serial, userEmail: message.owner!.email, groupId: message.owner!.group} + , Date.now() ) + ]) + sendToApp(ctx.data) + }) + on(LeaveGroupMessage, async (ctx, message) => { + await Promise.all([ + dbapi.setDeviceState(message.serial, {owner: null, usage: null, timeout: 0}), + dbapi.sendEvent('device_leave' + , {} + , {deviceSerial: message.serial, userEmail: message.owner!.email, groupId: message.owner!.group} + , Date.now() + ) + ]) + sendToApp(ctx.data) + }) + on(DeviceGetIsInOrigin, async (ctx, message) => { + const device = await DeviceModel.loadDeviceBySerial(message.serial) + const isInOrigin = device ? device.group.id === device.group.origin : false + ctx.sendToDevice(ctx.reply.okay('success', {isInOrigin})) + }) + on(DeviceIdentityMessage, (ctx, message) => { + dbapi.saveDeviceIdentity(message.serial, message) + sendToApp(ctx.data) + }) + on(AirplaneModeEvent, (ctx, message) => { + dbapi.setDeviceAirplaneMode(message.serial, message.enabled) + sendToApp(ctx.data) + }) + on(BatteryEvent, (ctx, message) => { + dbapi.setDeviceBattery(message.serial, message) + sendToApp(ctx.data) + }) + on(DeviceBrowserMessage, (ctx, message) => { + dbapi.setDeviceBrowser(message.serial, message) + sendToApp(ctx.data) + }) + on(ConnectivityEvent, (ctx, message) => { + dbapi.setDeviceConnectivity(message.serial, message) + sendToApp(ctx.data) + }) + on(PhoneStateEvent, (ctx, message) => { + dbapi.setDevicePhoneState(message.serial, message) + sendToApp(ctx.data) + }) + on(RotationEvent, (ctx, message) => { + dbapi.setDeviceRotation(message) + sendToApp(ctx.data) + }) + on(CapabilitiesMessage, (ctx, message) => { + dbapi.setDeviceCapabilities(message) + sendToApp(ctx.data) + }) + on(UpdateIosDevice, (ctx, message) => { + dbapi.updateIosDevice(message) + }) + on(SdkIosVersion, (ctx, message) => { + dbapi.setDeviceIosVersion(message) + }) + on(SizeIosDevice, (ctx, message) => { + dbapi.sizeIosDevice(message.id, message.height, message.width, message.scale, message.url) + sendToApp(ctx.data) + }) + on(DeviceTypeMessage, (ctx, message) => { + dbapi.setDeviceType(message.serial, message.type) + }) + on(GetServicesAvailabilityMessage, (ctx, message) => { + dbapi.setDeviceServicesAvailability(message.serial, message) + sendToApp(ctx.data) + }) + on(GetPresentDevices, async (ctx) => { + const devices = await DeviceModel.loadPresentDevices() + .then(devices => devices.map(d => d.serial)) + ctx.sendToDevice(ctx.reply.okay('success', {devices})) + }) + on(GetDeadDevices, async (ctx, message) => { + const deadDevices = await DeviceModel.getDeadDevice(message.time) + ctx.sendToDevice(ctx.reply.okay('success', {deadDevices})) + }) + on(DeleteDevice, async (ctx, message) => { + DeviceModel.deleteDevice(message.serial) + }) + + // Dispatch one already-decoded device Envelope to the matching handler with + // a context bound to the sending device. Types nobody consumes are + // forwarded to the app side verbatim. + const dispatchDeviceMessage = (senderId: Buffer, envelope: Buffer, decoded: Envelope) => { + const typeUrl = decoded.message?.typeUrl + if (!typeUrl) { + return } - catch (err: any) { - log.fatal('Unable to connect to dev dealer endpoint %s', err?.message) - lifecycle.fatal() + // reply is a lazy getter: most handlers never call it, so allocation is + // deferred to first access. defineProperty caches the result inline. + const ctx: DeviceContext = { + sendToDevice: (out) => sendToDevice(senderId, out), + data: envelope, + get reply() { + const r = makeReply(decoded.channel) + Object.defineProperty(this, 'reply', {value: r, writable: false}) + return r + }, } - })) + const entry = handlers.get(typeUrl) + if (!entry) { + // Unknown/unconsumed type: forward to the app side as-is. + sendToApp(envelope) + return + } + const message = Any.unpack(decoded.message!, entry.type) + Promise.resolve(entry.handle(ctx, message)).catch((err: any) => + log.error('Handler for %s failed: %s', entry.type.typeName, err?.message || err)) + } - const defaultWireHandler = - (channel: string, _: any, data: any) => - appDealer.send([channel, data]) - - const router = new WireRouter() - .on(UserChangeMessage, defaultWireHandler) - .on(GroupChangeMessage, defaultWireHandler) - .on(DeviceGroupChangeMessage, defaultWireHandler) - .on(GroupUserChangeMessage, defaultWireHandler) - .on(DeviceLogMessage, defaultWireHandler) - .on(TransactionProgressMessage, defaultWireHandler) - .on(TransactionDoneMessage, defaultWireHandler) - .on(TransactionTreeMessage, defaultWireHandler) - .on(InstallResultMessage, defaultWireHandler) - .on(DeviceLogcatEntryMessage, defaultWireHandler) - .on(TemporarilyUnavailableMessage, defaultWireHandler) - .on(UpdateRemoteConnectUrl, defaultWireHandler) - .on(InstalledApplications, defaultWireHandler) - .on(DeviceIntroductionMessage, async (channel, message, data) => { - await dbapi.saveDeviceInitialState(message.serial, message) - devDealer.send([ - message.provider!.channel, - wireutil.pack(DeviceRegisteredMessage, {serial: message.serial}) - ]) - appDealer.send([channel, data]) - }) - .on(DeviceIosIntroductionMessage, async(channel, message, data) => { - await dbapi.saveIosDeviceInitialState(options.publicIp, message) - devDealer.send([ - message.provider!.channel, - wireutil.pack(DeviceRegisteredMessage, {serial: message.serial}) - ]) - }) - .on(InitializeIosDeviceState, (channel, message, data) => { - dbapi.initializeIosDeviceState(options.publicIp, message) - }) - .on(DevicePresentMessage, async (channel, message, data) => { - await dbapi.setDevicePresent(message.serial) - appDealer.send([channel, data]) - }) - .on(DeviceAbsentMessage, async (channel, message, data) => { - await dbapi.setDeviceAbsent(message.serial) - appDealer.send([channel, data]) - }) - .on(DeviceStatusMessage, (channel, message, data) => { - dbapi.saveDeviceStatus(message.serial, message.status) - appDealer.send([channel, data]) - }) - .on(DeviceReadyMessage, async (channel, message, data) => { - await dbapi.setDeviceReady(message.serial, message.channel) - devDealer.send([message.channel, wireutil.envelope(new wire.ProbeMessage())]) - appDealer.send([channel, data]) - }) - .on(JoinGroupByAdbFingerprintMessage, async (channel, message) => { - try { - const user = await UserModel.lookupUserByAdbFingerprint(message.fingerprint) - if (user) { - devDealer.send([ - channel, - wireutil.envelope(new wire.AutoGroupMessage(new wire.OwnerMessage(user.email, user.name, user.group), message.fingerprint)) - ]) - return + // Type urls of the presence-relevant messages, precomputed once so the hot + // path compares raw strings without per-message typeUrl->name conversions. + const INTRO_URL = Any.typeNameToUrl(DeviceIntroductionMessage.typeName) + const INTRO_IOS_URL = Any.typeNameToUrl(DeviceIosIntroductionMessage.typeName) + const HEARTBEAT_URL = Any.typeNameToUrl(DeviceHeartbeatMessage.typeName) + + // A device introduced itself or is heartbeating: keep presence fresh. The + // (providerName, serial) come from the routing identity, not the payload. + const bumpPresence = (identity: Buffer, kind: 'intro' | 'heartbeat') => { + const {providerName, serial} = parseDeviceKey(identity) + + // A real message from the device promotes it off the startup sweep list: + // it is demonstrably alive and talking to US, so from here on its normal + // TTL governs it and no Mongo re-check is needed on expiry. + const wasSwept = sweptKeys.delete(deviceKey(providerName, serial)) + + // Announce ONLY when the provider is newly learned. learnProvider returns + // true exactly once per provider, so the proxy's provider->processor table + // is updated once and not on every heartbeat. + if (routing.learnProvider(providerName)) { + announceProvider(providerName) + } + + if (kind === 'intro' || wasSwept) { + // introduce() drops-silent then does a non-silent bump, which calls + // create() and emits 'insert' → DevicePresentMessage. This is also + // correct for a swept device whose first real message is a heartbeat: + // TTLSet already contains the silent seed, so plain bump() would + // just relink the existing item without emitting 'insert'. + presence.introduce(providerName, serial, Date.now()) + } + else { + presence.bump(providerName, serial, Date.now()) + } + } + + // Send a single [A, providerName] up to the proxy. Idempotent on the proxy. + const announceProvider = (providerName: string) => { + proxyDealer.send(encodeAnnounce(providerName)) + .catch((err: any) => log.warn('Announce failed: %s', err?.message)) + } + + // Inbound from devices (ROUTER): routing decisions in ProcessorRouting. + // via 'dealer' -> reply headed up to the proxy, forward as-is; + // via 'consume' -> device event, decode + dispatch locally. + deviceRouter.on('frames', (frames: Buffer[]) => { + try { + for (const send of routing.routeFromDevice(frames)) { + if (send.via === 'dealer') { + proxyDealer.send(send.frames).catch((err: any) => + log.warn('Reply to app failed: %s', err?.message)) } - appDealer.send([ - message.currentGroup, - wireutil.envelope(new wire.JoinGroupByAdbFingerprintMessage(message.serial, message.fingerprint, message.comment)) - ]) - } catch (err: any) { - log.error('Unable to lookup user by ADB fingerprint "%s": %s', message.fingerprint, err?.message) - } - }) - .on(JoinGroupByVncAuthResponseMessage, async (channel, message) => { - try { - const user = await dbapi.lookupUserByVncAuthResponse(message.response, message.serial) - if (user) { - devDealer.send([ - channel, - wireutil.envelope(new wire.AutoGroupMessage(new wire.OwnerMessage(user.email, user.name, user.group), message.response)) - ]) - return + else if (send.via === 'consume' && send.sender) { + consumeDeviceEvent(send.sender, send.frames[0]) } + } + } + catch (err: any) { + log.error('Device routing error: %s', err?.message || err) + } + }) - appDealer.send([ - message.currentGroup, - wireutil.envelope(new wire.JoinGroupByVncAuthResponseMessage(message.serial, message.response)) - ]) - } catch (err: any) { - log.error('Unable to lookup user by VNC auth response "%s": %s', message.response, err?.message) + // Handle a device event ([E] body): bump presence from the routing + // identity, then dispatch by message type. + const consumeDeviceEvent = (senderId: Buffer, envelope: Buffer) => { + // The processor consumes some types (dbapi) and forwards the rest to + // the app side. Decode once and reuse for the presence peek and the + // dispatch below. + const decoded = Envelope.fromBinary(envelope) + const typeUrl = decoded.message?.typeUrl + + // The (providerName, serial) for presence come from the routing + // identity, not the message body (a heartbeat only carries serial). + if (typeUrl === INTRO_URL || typeUrl === INTRO_IOS_URL) { + bumpPresence(senderId, 'intro') + } + else if (typeUrl === HEARTBEAT_URL) { + bumpPresence(senderId, 'heartbeat') + } + + // Dispatch with a context bound to THIS sender so concurrent devices + // never cross-address each other's replies. + dispatchDeviceMessage(senderId, envelope, decoded) + } + + // Inbound from the proxy (DEALER): [kind, ...] — device-directed commands. + proxyDealer.on('frames', (frames: Buffer[]) => { + try { + for (const send of routing.routeFromProxy(frames)) { + if (send.via === 'router' && send.target) { + deviceRouter.send([send.target, ...send.frames]).catch((err: any) => + log.warn('Undeliverable to %s: %s', send.target!.toString(), err?.message)) + } + else if (send.via === 'init') { + // [I, startedAt] — we are the elected processor for the + // startup presence reconciliation sweep. + const proxyStartedAt = Number(send.frames[0]?.toString()) + if (!Number.isFinite(proxyStartedAt)) { + log.warn('Ignoring INIT with unparseable timestamp "%s"', send.frames[0]?.toString()) + continue + } + runStartupSweep(proxyStartedAt).catch((err: any) => + log.error('Startup presence sweep failed: %s', err?.message || err)) + } } - }) - .on(ConnectStartedMessage, async (channel, message, data) => { - await dbapi.setDeviceConnectUrl(message.serial, message.url) - appDealer.send([channel, data]) - }) - .on(ConnectStoppedMessage, async (channel, message, data) => { - await dbapi.unsetDeviceConnectUrl(message.serial) - appDealer.send([channel, data]) - }) - .on(JoinGroupMessage, async (channel, message, data) => { - await Promise.all([ // @ts-ignore - dbapi.setDeviceState(message.serial, message), - dbapi.sendEvent(`device_${message.usage || 'use'}` - , {} - , {deviceSerial: message.serial, userEmail: message.owner!.email, groupId: message.owner!.group} - , Date.now() - ) - ]) - appDealer.send([channel, data]) - }) - .on(LeaveGroupMessage, async (channel, message, data) => { - await Promise.all([ - dbapi.setDeviceState(message.serial, {owner: null, usage: null, timeout: 0}), - dbapi.sendEvent('device_leave' - , {} - , {deviceSerial: message.serial, userEmail: message.owner!.email, groupId: message.owner!.group} - , Date.now() - ) - ]) - appDealer.send([channel, data]) - }) - .on(DeviceGetIsInOrigin, async (channel, message) => { - const device = await DeviceModel.loadDeviceBySerial(message.serial) - const isInOrigin = device ? device.group.id === device.group.origin : false - devDealer.send([ - channel, - reply.okay('success', {isInOrigin}) - ]) - }) - .on(DeviceIdentityMessage, (channel, message, data) => { - dbapi.saveDeviceIdentity(message.serial, message) - appDealer.send([channel, data]) - }) - .on(AirplaneModeEvent, (channel, message, data) => { - dbapi.setDeviceAirplaneMode(message.serial, message.enabled) - appDealer.send([channel, data]) - }) - .on(BatteryEvent, (channel, message, data) => { - dbapi.setDeviceBattery(message.serial, message) - appDealer.send([channel, data]) - }) - .on(DeviceBrowserMessage, (channel, message, data) => { - dbapi.setDeviceBrowser(message.serial, message) - appDealer.send([channel, data]) - }) - .on(ConnectivityEvent, (channel, message, data) => { - dbapi.setDeviceConnectivity(message.serial, message) - appDealer.send([channel, data]) - }) - .on(PhoneStateEvent, (channel, message, data) => { - dbapi.setDevicePhoneState(message.serial, message) - appDealer.send([channel, data]) - }) - .on(RotationEvent, (channel, message, data) => { - dbapi.setDeviceRotation(message) - appDealer.send([channel, data]) - }) - .on(CapabilitiesMessage, (channel, message, data) => { - dbapi.setDeviceCapabilities(message) - appDealer.send([channel, data]) - }) - .on(ReverseForwardsEvent, (channel, message, data) => { - dbapi.setDeviceReverseForwards(message.serial, message.forwards) - appDealer.send([channel, data]) - }) - .on(UpdateIosDevice, (channel, message, data) => - dbapi.updateIosDevice(message) - ) - .on(SdkIosVersion, (channel, message, data) => { - dbapi.setDeviceIosVersion(message) - }) - .on(SizeIosDevice, (channel, message, data) => { - dbapi.sizeIosDevice(message.id, message.height, message.width, message.scale, message.url) - appDealer.send([channel, data]) - }) - .on(DeviceTypeMessage, (channel, message, data) => { - dbapi.setDeviceType(message.serial, message.type) - }) - .on(GetServicesAvailabilityMessage, (channel, message, data) => { - dbapi.setDeviceServicesAvailability(message.serial, message) - appDealer.send([channel, data]) - }) - .on(GetPresentDevices, async (channel, message, data) => { - const devices = await DeviceModel.loadPresentDevices() - .then(devices => devices.map(d => d.serial)) - devDealer.send([ - channel, - reply.okay('success', {devices}) - ]) - }) - .on(DeviceHeartbeatMessage, (channel, message, data) => { - devDealer.send([ channel, data ]) - }) - .on(GetDeadDevices, async(channel, message, data) => { - const deadDevices = await DeviceModel.getDeadDevice(message.time) - devDealer.send([ - channel, - reply.okay('success', {deadDevices}) - ]) - }) - .on(DeleteDevice, async(channel, message, data) => { - DeviceModel.deleteDevice(message.serial) - }) - .handler(); + } + catch (err: any) { + log.error('Proxy routing error: %s', err?.message || err) + } + }) + + // Announce anything we already know (none at startup); from then on each + // provider is announced once, when it is first learned. + announce() + + // Identify ourselves as a processor. If we are the first one on this proxy, + // it answers with an INIT and we take on the startup presence sweep. + hello() - devDealer.on('message', router) + // A restarted proxy starts with an empty routing table. Since providers are + // announced only once (when first learned), reconnect replays both the full + // provider set and HELLO so the new proxy can elect an INIT recipient. + proxyDealer.watchReconnect().on('reconnect', () => { + log.info('Reconnected to the proxy, replaying announcements') + announce() + hello() + }) - lifecycle.observe(() => { - ;[appDealer, devDealer].forEach(function(sock) { + const shutdown = () => { + presence.stop() + ;[deviceRouter, proxyDealer].forEach((sock) => { try { sock.close() } @@ -328,5 +696,12 @@ export default db.ensureConnectivity(async(options: Options) => { log.error('Error while closing socket "%s"', err?.message) } }) - }) + } + + lifecycle.observe(shutdown) + + // Returned so callers/tests can drive teardown explicitly (mirrors the proxy + // unit). Leaking a processor leaks its presence TTL timers, which keep firing + // absent reaps long after the caller is done with it. + return {deviceRouter, proxyDealer, presence, routing, shutdown} }) diff --git a/lib/units/processor/presence.ts b/lib/units/processor/presence.ts new file mode 100644 index 0000000000..b540a3b46e --- /dev/null +++ b/lib/units/processor/presence.ts @@ -0,0 +1,76 @@ +// +// Presence tracking for the processor, on top of TTLSet. +// +// The uniqueness KEY is the deviceKey (providerName + serial): device serials +// are not globally unique (emulators share e.g. "emulator-5554"), so tracking +// by bare serial would collapse two distinct devices behind different +// providers. The emitted events, however, carry the bare serial to match the +// existing wire payload (DevicePresentMessage/DeviceAbsentMessage take +// {serial}). So the tracker remembers deviceKey -> serial and reports the right +// serial on timeout. +// +import {EventEmitter} from 'events' +import TTLSet from '../../util/ttlset.js' +import {deviceKey} from '../../wire/frame.js' + +export interface PresenceEvent { + serial: string + providerName: string +} + +export interface PresenceTracker { + on(event: 'present' | 'absent', listener: (e: PresenceEvent) => void): this +} + +export class PresenceTracker extends EventEmitter { + private ttlset: TTLSet + // deviceKey -> {serial, providerName}, so a timeout drop can report them. + private meta = new Map() + + constructor(ttlMs: number) { + super() + this.ttlset = new TTLSet(ttlMs) + + this.ttlset.on('insert', (key: string) => { + const e = this.meta.get(key) + if (e) { + this.emit('present', e) + } + }) + + this.ttlset.on('drop', (key: string) => { + const e = this.meta.get(key) + this.meta.delete(key) + if (e) { + this.emit('absent', e) + } + }) + } + + // Record a heartbeat for a device. Emits 'present' the first time (unless + // silent, e.g. during initial state load). + bump(providerName: string, serial: string, time: number, opts: {silent?: boolean} = {}) { + const key = deviceKey(providerName, serial) + this.meta.set(key, {serial, providerName}) + this.ttlset.bump(key, time, opts.silent ? TTLSet.SILENT : 0) + } + + // A device introduction: drop any stale entry silently, then bump so a + // fresh 'present' is emitted (mirrors the reaper introduction flow). + introduce(providerName: string, serial: string, time: number) { + const key = deviceKey(providerName, serial) + this.ttlset.drop(key, TTLSet.SILENT) + this.bump(providerName, serial, time) + } + + // Drop a device silently (e.g. an explicit absent from the device). + forget(providerName: string, serial: string) { + const key = deviceKey(providerName, serial) + this.meta.delete(key) + this.ttlset.drop(key, TTLSet.SILENT) + } + + stop() { + this.ttlset.stop() + } +} diff --git a/lib/units/processor/routing.ts b/lib/units/processor/routing.ts new file mode 100644 index 0000000000..123506489c --- /dev/null +++ b/lib/units/processor/routing.ts @@ -0,0 +1,117 @@ +// +// Pure routing logic for the processor (no ZMQ I/O — testable in isolation). +// +// The processor sits between the proxy and the devices. It has TWO inputs with +// DIFFERENT framing: +// +// - from the proxy over a DEALER: [kind, ...rest, body] +// (the DEALER has already stripped any identity frame); +// - from a device over its own ROUTER: [deviceKeyId, kind, ...rest, body] +// (the ROUTER prepends the sender deviceKey as frame 0). +// +// Each entry point returns Send[] tagged with the channel to send on: +// via 'router' -> down to a device (target = deviceKey); +// via 'dealer' -> up to the proxy (no target; the single DEALER link is implicit). +// +// The processor never decodes the Envelope body and, when moving a command DOWN +// to a device, it does NOT touch the reply-path — the return path to the proxy +// is carried implicitly by the single DEALER link. +// +import {KIND, deviceKey, encodeAnnounce, classifyFrames} from '../../wire/frame.js' + +export interface Send { + // 'router' -> down to a device: router.send([target, ...frames]); + // 'dealer' -> up to the proxy: dealer.send(frames); + // 'consume' -> decode + dispatch locally (dbapi / forward-to-app). The + // raw Envelope body is in frames[0]; `sender` is the device + // routing identity so a reply can be addressed back to it. + // 'init' -> the proxy's one-shot startup handoff; frames[0] is the + // proxy's start timestamp as a decimal string. + via: 'router' | 'dealer' | 'consume' | 'init' + target?: Buffer + sender?: Buffer + frames: Buffer[] +} + +export class ProcessorRouting { + // The set of providerNames this processor owns. The proxy routes a device + // command here only after learning this ownership via an ANNOUNCE, so the + // processor must (re)announce every known provider whenever its DEALER link + // to the proxy (re)connects — otherwise a restarted proxy has an empty table. + private providers = new Set() + + // Record that this processor owns a providerName (learned from a device + // introduction). Idempotent. Returns true only when the provider was newly + // learned, so the caller can announce it to the proxy exactly once instead + // of on every device introduction. + learnProvider(providerName: string): boolean { + if (this.providers.has(providerName)) { + return false + } + this.providers.add(providerName) + return true + } + + // One ANNOUNCE frame per known provider, to be sent up the DEALER link on + // (re)connect. [A, providerName] + announceFrames(): Send[] { + return [...this.providers].map(providerName => ({ + via: 'dealer' as const, + frames: encodeAnnounce(providerName), + })) + } + + // Route a message arriving from the proxy (over the DEALER). Framing is + // [kind, ...] with no identity frame. + routeFromProxy(frames: Buffer[]): Send[] { + // From the proxy over a DEALER: no prepended identity. + const {kind, message} = classifyFrames(frames, {routerPrepended: false}) + + switch (kind) { + case KIND.DEVICE: + return this.routeDeviceDown(message) + case KIND.INIT: + // [I, startedAt] — surface the timestamp frame for the glue to + // parse and act on. Arrives at most once per proxy lifetime. + return [{via: 'init', frames: message.slice(1)}] + default: + return [] + } + } + + // Route a message arriving from a device (over the ROUTER). Framing is + // [deviceKeyId, kind, ...] with the sender deviceKey prepended by the ROUTER. + // + // A device only ever emits two kinds: + // R -> a reply headed for the app; forward it up to the proxy unchanged. + // E -> a device event; hand it to the glue to decode + dispatch locally + // (the processor decides, by message type, whether to consume it in + // dbapi or forward it to the app side). + routeFromDevice(frames: Buffer[]): Send[] { + // From a device over the ROUTER: frame 0 is the sender deviceKey. + const {sender, kind, message} = classifyFrames(frames, {routerPrepended: true}) + + switch (kind) { + case KIND.REPLY: + // [R, ...replyPath, body] — forward up to the proxy unchanged. + return [{via: 'dealer', frames: message}] + case KIND.EVENT: + // [E, body] — surface the raw body for local dispatch. + return [{via: 'consume', sender: sender!, frames: message.slice(1)}] + default: + return [] + } + } + + private routeDeviceDown(frames: Buffer[]): Send[] { + // [D, providerName, serial, ...replyPath, body] + const providerName = frames[1].toString() + const serial = frames[2].toString() + return [{ + via: 'router', + target: Buffer.from(deviceKey(providerName, serial)), + // forward as-is: the processor does not push its own identity. + frames, + }] + } +} diff --git a/lib/units/provider/index.ts b/lib/units/provider/index.ts index ad45df256b..39119a4cdc 100644 --- a/lib/units/provider/index.ts +++ b/lib/units/provider/index.ts @@ -1,25 +1,13 @@ import logger from '../../util/logger.js' -import wireutil from '../../wire/util.js' -import {WireRouter} from '../../wire/router.js' import lifecycle from '../../util/lifecycle.js' import srv from '../../util/srv.js' -import * as zmqutil from '../../util/zmqutil.js' import {ChildProcess} from 'node:child_process' import ADBObserver, {ADBDevice} from './ADBObserver.js' -import { - DeviceRegisteredMessage, - DeviceAbsentMessage, - DeviceIntroductionMessage, - ProviderMessage, - DeviceStatusMessage -} from '../../wire/wire.js' import {ProcessManager, ResourcePool} from '../../util/ProcessManager.js' // Device-specific context for process management interface DeviceContext { device: ADBDevice - resolveRegister?: () => void - register: Promise } type DeviceHandler = (device: ADBDevice, oldType?: ADBDevice['type']) => any | Promise @@ -33,11 +21,13 @@ export interface Options { killTimeout: number deviceType: string endpoints: { - push: string[] - sub: string[] + // processor ROUTER endpoint(s) — resolved once at startup via SRV + // weighted selection. The winning URL is passed to every forked device + // so all devices under this provider share one processor. + processor: string[] } filter: (serial: string) => boolean - fork: (serial: string, ports: number[]) => ChildProcess + fork: (serial: string, ports: number[], processorEndpoint: string) => ChildProcess } export default async (options: Options): Promise => { @@ -54,46 +44,29 @@ export default async (options: Options): Promise => { lifecycle.fatal() } - const solo = wireutil.makePrivateChannel() - - // To make sure that we always bind the same type of service to the same - // port, we must ensure that we allocate ports in fixed groups. - options.ports = options.ports.slice(0, options.ports.length - options.ports.length % RESOURCE_ALLOCATION_COUNT) - - // Output - const push = zmqutil.socket('push') + // Resolve all endpoints, flatten records, pick first via srv.attempt. + // Failover between records works only with srv+tcp:// (DNS-level selection). + // Plain tcp:// always resolves to one record — ZMQ handles reconnects itself. + // All forked devices get the same URL — one processor per provider. + let selectedProcessorEndpoint: string try { - await Promise.all(options.endpoints.push.map(async(endpoint) => { - const records = await srv.resolve(endpoint) - return srv.attempt(records, (record) => { - log.info('Sending output to "%s"', record.url) - push.connect(record.url) - }) - })) + const allRecords = ( + await Promise.all(options.endpoints.processor.map(e => srv.resolve(e))) + ).flat() + selectedProcessorEndpoint = await srv.attempt(allRecords, (record) => { + log.info('Provider "%s": devices will connect to processor "%s"', options.name, record.url) + return record.url + }) } - catch (err) { - log.fatal('Unable to connect to push endpoint: %s', err) + catch (err: any) { + log.fatal('Unable to resolve processor endpoint: %s', err?.message || err) lifecycle.fatal() + return } - // Input - const sub = zmqutil.socket('sub') - try { - await Promise.all(options.endpoints.sub.map(async(endpoint) => { - const records = await srv.resolve(endpoint) - return srv.attempt(records, (record) => { - log.info('Receiving input to "%s"', record.url) - sub.connect(record.url) - }) - })) - - log.info('Subscribing to permanent channel "%s"', solo) - sub.subscribe(solo) - } - catch (err) { - log.fatal('Unable to connect to sub endpoint: %s', err) - lifecycle.fatal() - } + // To make sure that we always bind the same type of service to the same + // port, we must ensure that we allocate ports in fixed groups. + options.ports = options.ports.slice(0, options.ports.length - options.ports.length % RESOURCE_ALLOCATION_COUNT) // Resource pool for port allocation const portPool = new ResourcePool(options.ports) @@ -101,7 +74,7 @@ export default async (options: Options): Promise => { const processManager = new ProcessManager({ spawn: (id, context, resources) => { log.info('Spawning device process "%s" with ports [%s]', id, resources.join(', ')) - return options.fork(id, resources) + return options.fork(id, resources, selectedProcessorEndpoint) }, onReady: (id) => { log.info('Device process "%s" is ready', id) @@ -109,15 +82,8 @@ export default async (options: Options): Promise => { onError: (id, context, error) => { log.error('Device process "%s" error: %s', id, error.message) }, - onCleanup: (id, context) => { - // Resolve register if pending - context.resolveRegister?.() - - // Tell others the device is gone - push.send([ - wireutil.global, - wireutil.pack(DeviceAbsentMessage, { serial: id }) - ]) + onCleanup: (id) => { + log.info('Device process "%s" cleaned up', id) } }, { killTimeout: options.killTimeout, @@ -127,15 +93,6 @@ export default async (options: Options): Promise => { resourcePool: portPool }) - // Handle device registration messages - sub.on('message', new WireRouter() - .on(DeviceRegisteredMessage, (channel, message: {serial: string}) => { - const process = processManager.get(message.serial) - process?.context?.resolveRegister?.() - }) - .handler() - ) - let statsTimer: NodeJS.Timeout const stats = (twice = true) => { const processStats = processManager.getStats() @@ -182,85 +139,27 @@ export default async (options: Options): Promise => { } } - // Tell others we found a device - const register = (device: ADBDevice) => new Promise( - async(resolve, reject) => { - push.send([ - wireutil.global, - wireutil.pack(DeviceIntroductionMessage, { - serial: device.serial, - status: wireutil.toDeviceStatus(device.type) || 1, - provider: ProviderMessage.create({ - channel: solo, - name: options.name - }), - deviceType: options.deviceType - }) - ]) - - log.info('Registering device "%s"', device.serial) - - process.nextTick(() => { // after creating process context - const managedProcess = processManager.get(device.serial) - if (!managedProcess) return - - const timeout = setTimeout(() => reject('Register timeout'), BASE_DELAY) - managedProcess.context.resolveRegister = () => { - clearTimeout(timeout) - delete managedProcess?.context?.resolveRegister - resolve() - } - }) - } - ) - - const startDeviceWork = async(device: ADBDevice, restart = false, reregister = false) => { + const startDeviceWork = async(device: ADBDevice, restart = false) => { if (!processManager.has(device.serial)) return stats(false) const managedProcess = processManager.get(device.serial) if (!managedProcess) return - if (restart || reregister) { - log.warn('Trying to %s device again, delay 10 sec [%s]', restart ? 'start' : 'register', device.serial) + if (restart) { + log.warn('Trying to start device again, delay 10 sec [%s]', device.serial) await new Promise(r => setTimeout(r, BASE_DELAY)) } log.info('Starting work for device "%s"', device.serial) - if (reregister) { - managedProcess.context.register = register(device) - } - - if (!restart) { - try { - // Wait for registration - await managedProcess.context.register - log.info('Device "%s" registered successfully', device.serial) - } - catch (err: any) { - log.error('Device "%s" registration failed: %s', device.serial, err?.message || err) - return startDeviceWork(device, false, true) - } - } - - // Start the process (this will spawn and wait for ready) const started = await processManager.start(device.serial) if (!started) { log.error('Failed to start device process [%s]', device.serial) - return startDeviceWork(device, true, false) + return startDeviceWork(device, true) } stats() - - // Tell others the device state changed - push.send([ - wireutil.global, - wireutil.pack(DeviceStatusMessage, { - serial: device.serial, - status: wireutil.toDeviceStatus(device.type) - }) - ]) } // Track and manage devices @@ -303,14 +202,8 @@ export default async (options: Options): Promise => { log.info('Connected device "%s" [%s]', device.serial, device.type) - // Create device context with registration promise - const deviceContext: DeviceContext = { - device, - register: register(device) // Register device immediately, before 'running' state - } - // Create managed process - const created = await processManager.create(device.serial, deviceContext, { + const created = await processManager.create(device.serial, {device}, { initialState: 'waiting', resourceCount: RESOURCE_ALLOCATION_COUNT // Allocate 2 ports per device }) @@ -417,10 +310,6 @@ export default async (options: Options): Promise => { stats(false) tracker.destroy() - ;[push, sub].forEach((sock) => - sock.close() - ) - // Clean up all processes return processManager.cleanup() }) diff --git a/lib/units/proxy/index.ts b/lib/units/proxy/index.ts new file mode 100644 index 0000000000..52d1c98025 --- /dev/null +++ b/lib/units/proxy/index.ts @@ -0,0 +1,52 @@ +// +// proxy: the single central ROUTER (bind), the only public entry for the app +// side. It reads only the routing header frames and never decodes the Envelope +// body. All routing decisions live in the pure ProxyRouting module; this unit +// is just the ZMQ I/O glue. +// +import logger from '../../util/logger.js' +import lifecycle from '../../util/lifecycle.js' +import {RouterSocket} from '../../util/zmqsocket.js' +import {ProxyRouting} from './routing.js' + +interface Options { + name?: string + endpoints: { + router: string + } +} + +export default async (options: Options) => { + const log = logger.createLogger('proxy') + if (options.name) { + logger.setGlobalIdentifier(options.name) + } + + const routing = new ProxyRouting() + const router = new RouterSocket() + + // Fire-and-forget: with mandatory=true the send is in-memory and nearly + // instant, so awaiting each one buys nothing. EHOSTUNREACH for a gone peer + // is caught and logged per send. + router.on('frames', (frames: Buffer[]) => { + try { + const sends = routing.route(frames) + for (const {target, frames: out} of sends) { + router.send([target, ...out]).catch((err: any) => { + log.warn('Undeliverable to %s: %s', target.toString(), err?.message) + }) + } + } + catch (err: any) { + log.error('Routing error: %s', err?.message || err) + } + }) + + await router.bind(options.endpoints.router) + log.info('proxy ROUTER listening on %s', options.endpoints.router) + + lifecycle.observe(() => router.close()) + + // Returned so callers/tests can drive teardown explicitly. + return {router, routing} +} diff --git a/lib/units/proxy/routing.ts b/lib/units/proxy/routing.ts new file mode 100644 index 0000000000..9fdc14892b --- /dev/null +++ b/lib/units/proxy/routing.ts @@ -0,0 +1,136 @@ +// +// Pure routing logic for the proxy (no ZMQ I/O — testable in isolation). +// +// The proxy is a ROUTER (bind). Its ROUTER prepends the sender routing id as +// frame 0 on every inbound multipart. So an inbound message is: +// +// [ senderId, kind, ...rest, body ] +// +// where kind (frame 1) drives the decision: +// D device-directed -> forward to the processor owning the providerName, +// pushing the sender onto the reply-path; +// R reply -> pop the top reply-path id and send there; +// B broadcast-to-app -> fan out to registered broadcast receivers. +// +// The proxy never decodes the Envelope body. +// +import {KIND, pushReplyPath, popReplyPath, classifyFrames, encodeInit} from '../../wire/frame.js' + +export interface Send { + target: Buffer + frames: Buffer[] +} + +export class ProxyRouting { + // providerName -> processor routing id + private providers = new Map() + + // Explicitly-registered broadcast receivers (websocket, log), keyed by hex + // of the routing id so lookups/removals are by value, not reference. + private broadcastReceivers = new Map() + + // Whether the one-shot startup INIT has been handed out. Latched on the + // FIRST inbound HELLO and never reset, so exactly one processor per proxy + // lifetime is elected to run the presence reconciliation sweep. + private initSent = false + + // `startedAt` is this proxy's start time, sent verbatim in the INIT. It is + // taken at construction (i.e. at proxy unit startup) so the unit itself has + // nothing extra to wire up; tests can pin it explicitly. + constructor(private startedAt: number = Date.now()) {} + + // A processor announces which providerName it owns. + announceProvider(providerName: string, processorId: Buffer) { + this.providers.set(providerName, processorId) + } + + // Forget every provider owned by a processor (e.g. it disconnected). + forgetProcessor(processorId: Buffer) { + for (const [name, id] of this.providers) { + if (id.equals(processorId)) { + this.providers.delete(name) + } + } + } + + registerBroadcastReceiver(routingId: Buffer) { + this.broadcastReceivers.set(routingId.toString('hex'), routingId) + } + + unregisterBroadcastReceiver(routingId: Buffer) { + this.broadcastReceivers.delete(routingId.toString('hex')) + } + + // Decide the outbound sends for one inbound multipart. Frame 0 is the + // sender routing id (added by the ROUTER). + route(inbound: Buffer[]): Send[] { + // The proxy is a ROUTER: frame 0 is the sender identity. + const {sender, kind, message} = classifyFrames(inbound, {routerPrepended: true}) + const senderId = sender! + + switch (kind) { + case KIND.DEVICE: + return this.routeDevice(senderId, message) + case KIND.REPLY: + return this.routeReply(message) + case KIND.BROADCAST: + return this.routeBroadcast(message) + case KIND.ANNOUNCE: + // [A, providerName] — the sender owns this provider. + this.announceProvider(message[1].toString(), senderId) + return [] + case KIND.SUBSCRIBE_BROADCAST: + // [S] — the sender wants broadcast-to-app events. + this.registerBroadcastReceiver(senderId) + return [] + case KIND.HELLO: + // [H] — a processor has connected. The first one to say hello + // gets the startup INIT; everyone after it gets nothing. HELLO + // is sent once per processor, so this is not a hot path. + return this.routeHello(senderId) + default: + return [] + } + } + + // Elect the first processor to connect and hand it the startup INIT. The + // INIT carries our start timestamp; the processor decides from it whether it + // outlived us and therefore owes a presence reconciliation sweep. + private routeHello(senderId: Buffer): Send[] { + if (this.initSent) { + return [] + } + this.initSent = true + return [{target: senderId, frames: encodeInit(this.startedAt)}] + } + + private routeBroadcast(message: Buffer[]): Send[] { + return [...this.broadcastReceivers.values()].map(target => ({ + target, + frames: message, + })) + } + + private routeReply(message: Buffer[]): Send[] { + // message = [R, ...replyPath, body]; pop the top id and send there. + const popped = popReplyPath(message) + if (!popped.routingId) { + // No reply-path left: nowhere for the proxy to forward. Drop. + return [] + } + return [{target: popped.routingId, frames: popped.frames}] + } + + private routeDevice(senderId: Buffer, message: Buffer[]): Send[] { + // message = [D, providerName, serial, ...replyPath, body] + const providerName = message[1].toString() + const processorId = this.providers.get(providerName) + if (!processorId) { + return [] + } + return [{ + target: processorId, + frames: pushReplyPath(message, senderId), + }] + } +} diff --git a/lib/units/reaper/index.ts b/lib/units/reaper/index.ts deleted file mode 100644 index 2fde098619..0000000000 --- a/lib/units/reaper/index.ts +++ /dev/null @@ -1,174 +0,0 @@ -import logger from '../../util/logger.js' -import { - DeleteDevice, - DeviceAbsentMessage, - DeviceHeartbeatMessage, - DeviceIntroductionMessage, DevicePresentMessage, GetDeadDevices, - GetPresentDevices -} from '../../wire/wire.js' -import wireutil from '../../wire/util.js' -import {WireRouter} from '../../wire/router.js' -import lifecycle from '../../util/lifecycle.js' -import srv from '../../util/srv.js' -import TTLSet from '../../util/ttlset.js' -import * as zmqutil from '../../util/zmqutil.js' -import {runTransactionDev} from '../../wire/transmanager.js' - -const log = logger.createLogger('reaper') - -interface Options { - heartbeatTimeout: number - timeToDeviceCleanup: number // in minutes - deviceCleanupInterval: number // in minutes - endpoints: { - sub: string[] - push: string[] - } -} - -export default (async(options: Options) => { - const ttlset = new TTLSet(options.heartbeatTimeout) - - // Input - const sub = zmqutil.socket('sub') - await Promise.all(options.endpoints.sub.map((endpoint: string) => - srv.resolve(endpoint).then(records => - srv.attempt(records, (record) => { - log.info('Receiving input from "%s"', record.url) - sub.connect(record.url) - }) - ) - )).catch((err) => { - log.fatal('Unable to connect to sub endpoint %s', err?.message) - lifecycle.fatal() - }) - - ;[wireutil.global].forEach(channel => { - log.info('Subscribing to permanent channel "%s"', channel) - sub.subscribe(channel) - }) - - // Output - const push = zmqutil.socket('push') - await Promise.all(options.endpoints.push.map((endpoint: string) => - srv.resolve(endpoint).then(records => - srv.attempt(records, (record) => { - log.info('Sending output to "%s"', record.url) - push.connect(record.url) - }) - ) - )).catch((err) => { - log.fatal('Unable to connect to push endpoint: %s', err?.message) - lifecycle.fatal() - }) - - ttlset.on('insert', (serial) => { - log.info('Device "%s" is present', serial) - push.send([ - wireutil.global, - wireutil.pack(DevicePresentMessage, {serial}) - ]) - }) - - ttlset.on('drop', (serial) => { - log.info('Reaping device "%s" due to heartbeat timeout', serial) - push.send([ - wireutil.global, - wireutil.pack(DeviceAbsentMessage, {serial}) - ]) - }) - - lifecycle.observe(() => { - [push, sub].forEach(sock => { - try { - sock.close() - } - catch (err: any) { - // no-op - } - }) - ttlset.stop() - }) - - const router = new WireRouter() - .on(DeviceIntroductionMessage, (channel, message) => { - ttlset.drop(message.serial, TTLSet.SILENT) - ttlset.bump(message.serial, Date.now()) - }) - .on(DeviceHeartbeatMessage, (channel, message) => { - ttlset.bump(message.serial, Date.now()) - }) - .on(DeviceAbsentMessage, (channel, message) => { - ttlset.drop(message.serial, TTLSet.SILENT) - }) - - if (options.timeToDeviceCleanup) { - log.info('deviceCleanerLoop enabled') - - // This functionality is implemented in the Reaper unit because this unit cannot be replicated - const deviceCleanerLoop = () => setTimeout(async() => { - log.info('Checking dead devices [interval: %s]', options.deviceCleanupInterval) - try { - const absenceDuration = options.timeToDeviceCleanup - const {deadDevices} = await runTransactionDev(wireutil.global, GetDeadDevices, { - time: options.timeToDeviceCleanup * 60 * 1000 - }, {sub, push, router}) - - for (const {serial, present} of deadDevices) { - if (present) { - continue - } - - log.info( // @ts-ignore - 'Removing a dead device [serial: %s, absence_duration: %.1f %s]', - serial, - ... ( - absenceDuration >= 60 // if more 1 hour - ? [absenceDuration / 60, 'hrs'] - : [absenceDuration, 'min'] - ) - ) - - push.send([ - wireutil.global, - wireutil.pack(DeleteDevice, {serial}) - ]) - } - } catch (err: any) { - log.error('Dead device check failed with error: %s', err?.message) - } finally { - deviceCleanerLoop() - } - }, options.deviceCleanupInterval * 60 * 1000) - - deviceCleanerLoop() - } - - const init = async() => { - try { - log.info('Reaping devices with no heartbeat') - - // Listen to changes - sub.on('message', router.handler()) - - // Load initial state - const {devices} = await runTransactionDev(wireutil.global, GetPresentDevices, {}, {sub, push, router}) - - const now = Date.now() - devices?.forEach((serial: string) => { - ttlset.bump(serial, now, TTLSet.SILENT) - }) - } - catch (err: any) { - if (err?.message === 'Timeout when running transaction') { - log.error('Load initial state error: Timeout when running transaction, retry') - setTimeout(init, 2000) - return - } - log.fatal('Unable to load initial state: %s', err?.message) - lifecycle.fatal() - } - } - - init() -}) diff --git a/lib/units/tizen-device/index.js b/lib/units/tizen-device/index.js index b2368f042d..30f0d1f790 100644 --- a/lib/units/tizen-device/index.js +++ b/lib/units/tizen-device/index.js @@ -1,11 +1,9 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../util/logger.js' import lifecycle from '../../util/lifecycle.js' -import logger$0 from '../base-device/support/logger.js' import heartbeat from '../base-device/plugins/heartbeat.js' import solo from '../base-device/plugins/solo.js' -import push from '../base-device/support/push.js' -import sub from '../base-device/support/sub.js' +import transport from '../base-device/support/transport.js' import group from '../base-device/plugins/group.js' import connect from './plugins/sdb/connect.js' import sdb from './plugins/sdb/index.js' @@ -33,7 +31,6 @@ const isTcpPortOpen = (host, port, timeout = 2_000) => new Promise((resolve) => export default (async(options) => syrup.serial() - .dependency(logger$0) .define(async(options) => { log.info('Preparing device') if (!await isTcpPortOpen(options.host, options.port)) { @@ -44,16 +41,15 @@ export default (async(options) => syrup.serial() return syrup.serial() .dependency(heartbeat) .dependency(solo) - .dependency(push) + .dependency(transport) .dependency(connect) - .dependency(sub) .dependency(router) .dependency(sdb) .dependency(group) .dependency(identity) .dependency(launcher) .dependency(filesystem) - .define(async(options, heartbeat, solo, push, connect, sub, router, sdb, group, identity, launcher, filesystem) => { + .define(async(options, heartbeat, solo, transport, connect, router, sdb, group, identity, launcher, filesystem) => { try { connect() @@ -94,7 +90,7 @@ export default (async(options) => syrup.serial() const status = ['device', 'offline'].includes(identity.status) ? identity.status : 'device' - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceIntroductionMessage( sdb.serial, diff --git a/lib/units/tizen-device/plugins/filesystem.js b/lib/units/tizen-device/plugins/filesystem.js index 63564c20ef..ab6a05f247 100644 --- a/lib/units/tizen-device/plugins/filesystem.js +++ b/lib/units/tizen-device/plugins/filesystem.js @@ -1,6 +1,6 @@ import syrup from '@devicefarmer/stf-syrup' import sdb from './sdb/index.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import router from '../../base-device/support/router.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' @@ -12,11 +12,11 @@ import {unlink} from 'fs' import {FileSystemGetMessage, FileSystemListMessage} from '../../../wire/wire.js' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(router) .dependency(sdb) .dependency(storage) - .define((options, push, router, sdb, storage) => () => { + .define((options, transport, router, sdb, storage) => () => { const log = logger.createLogger('tizen-device:plugins:filesystem') const reply = wireutil.reply(options.serial) @@ -45,14 +45,14 @@ export default syrup.serial() router.on(FileSystemGetMessage, async(channel, message) => { try { const file = await uploadFromDevice(message.file, message.jwt) - push.send([ + transport.send([ channel, reply.okay('success', file) ]) } catch (/** @type {any} */ err) { log.warn('Unable to retrieve "%s"', message.file, err) - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -80,13 +80,13 @@ export default syrup.serial() router.on(FileSystemListMessage, async(channel, message) => { try { - push.send([ + transport.send([ channel, reply.okay('success', await ls(message.dir)) ]) } catch (/** @type {any} */ err) { - push.send([ + transport.send([ channel, reply.fail(err?.message || err) ]) diff --git a/lib/units/tizen-device/plugins/identity.js b/lib/units/tizen-device/plugins/identity.js index 9696f3e19f..56ea60e6b9 100644 --- a/lib/units/tizen-device/plugins/identity.js +++ b/lib/units/tizen-device/plugins/identity.js @@ -3,15 +3,15 @@ import sdb from './sdb/index.js' import router from '../../base-device/support/router.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import {exec} from 'child_process' import {ProbeMessage} from '../../../wire/wire.js' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(router) .dependency(sdb) - .define((options, push, router, sdb) => { + .define((options, transport, router, sdb) => { const identity = { collect: async() => { const display = await sdb.shell('cat /sys/class/graphics/fb0/virtual_size') || '0,0' @@ -31,7 +31,7 @@ export default syrup.serial() }) router.on(ProbeMessage, () => { - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceIdentityMessage( options.serial, diff --git a/lib/units/tizen-device/plugins/install.js b/lib/units/tizen-device/plugins/install.js index ceb3a5de1c..ba8a50f0b1 100644 --- a/lib/units/tizen-device/plugins/install.js +++ b/lib/units/tizen-device/plugins/install.js @@ -2,7 +2,7 @@ import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import storage from '../../base-device/support/storage.js' import sdb from './sdb' import launcher from './launcher' @@ -11,11 +11,11 @@ import {InstallMessage, UninstallIosMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(router) - .dependency(push) + .dependency(transport) .dependency(storage) .dependency(sdb) .dependency(launcher) - .define((options, router, push, storage, sdb, launcher) => { + .define((options, router, transport, storage, sdb, launcher) => { const log = logger.createLogger('tizen-device:plugins:install') const reply = wireutil.reply(options.serial) @@ -23,7 +23,7 @@ export default syrup.serial() log.info('Installing application from "%s"', message.href, message.launch ? '[ LAUNCH ]' : '') const sendProgress = (data, progress) => - push.send([channel, reply.progress(data, progress)]) + transport.send([channel, reply.progress(data, progress)]) sendProgress('starting', 0) diff --git a/lib/units/tizen-device/plugins/launcher.js b/lib/units/tizen-device/plugins/launcher.js index 42339ccf40..6db9c8ed15 100644 --- a/lib/units/tizen-device/plugins/launcher.js +++ b/lib/units/tizen-device/plugins/launcher.js @@ -1,5 +1,5 @@ import syrup from '@devicefarmer/stf-syrup' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import router from '../../base-device/support/router.js' import group from '../../base-device/plugins/group.js' import sdb from './sdb/index.js' @@ -9,15 +9,15 @@ import webinspector from './webinspector/index.js' import {GetInstalledApplications, KillDeviceApp, LaunchDeviceApp, TerminateDeviceApp} from '../../../wire/wire.js' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(router) .dependency(sdb) .dependency(webinspector) .dependency(group) - .define((options, push, router, sdb, webinspector, group) => { + .define((options, transport, router, sdb, webinspector, group) => { const reply = wireutil.reply(options.serial) - const success = (channel, body) => push.send([channel, reply.okay('success', body)]) + const success = (channel, body) => transport.send([channel, reply.okay('success', body)]) const plugin = { app: null, @@ -44,7 +44,7 @@ export default syrup.serial() .catch(({message: error}) => ({error})) if ('error' in plugin.app) { - push.send([channel, reply.fail('fail', plugin.app)]) + transport.send([channel, reply.fail('fail', plugin.app)]) return } await sdb.forwardPort(options.appInspectPort, plugin.app.port) // only for external webinspector connection diff --git a/lib/units/tizen-device/plugins/webinspector/index.ts b/lib/units/tizen-device/plugins/webinspector/index.ts index 29d029187b..8fb7e74736 100644 --- a/lib/units/tizen-device/plugins/webinspector/index.ts +++ b/lib/units/tizen-device/plugins/webinspector/index.ts @@ -1,4 +1,4 @@ -import push from '../../../base-device/support/push.js' +import transport from '../../../base-device/support/transport.js' import router from '../../../base-device/support/router.js' import group from '../../../base-device/plugins/group.js' import cdp, {CDPClient} from '../cdp/index.js' @@ -81,20 +81,20 @@ const inspectServer = (port: number, cdp: CDPClient, log: any) => }) export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(router) .dependency(cdp) .dependency(group) .dependency(urlformat) - .define((options, push, router, cdp, group, urlformat) => { + .define((options, transport, router, cdp, group, urlformat) => { const log = logger.createLogger('tizen-device:plugins:webinspector') const reply = wireutil.reply(options.serial) let frameId: string | null = null const success = (channel: string, body: any) => - push.send([channel, reply.okay('success', body)]) + transport.send([channel, reply.okay('success', body)]) const fail = (channel: string, err: any) => - push.send([channel, reply.fail('fail', err?.message || err)]) + transport.send([channel, reply.fail('fail', err?.message || err)]) const getAssetsList = async() => { const result = await cdp.getAssetsList() diff --git a/lib/units/triproxy/index.js b/lib/units/triproxy/index.js deleted file mode 100644 index a97505ed41..0000000000 --- a/lib/units/triproxy/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import logger from '../../util/logger.js' -import lifecycle from '../../util/lifecycle.js' -import * as zmqutil from '../../util/zmqutil.js' -export default (function(options) { - const log = logger.createLogger('triproxy') - if (options.name) { - logger.setGlobalIdentifier(options.name) - } - function proxy(to) { - return function() { - log.verbose('Proxied to: ' + to.type + ' with args \n' + arguments[0].toString() + '\n' + arguments[1].toString()) - to.send([].slice.call(arguments)) - } - } - // App/device output - let pub = zmqutil.socket('pub') - pub.bindSync(options.endpoints.pub) - log.info('PUB socket bound on', options.endpoints.pub) - // Coordinator input/output - let dealer = zmqutil.socket('dealer') - dealer.bindSync(options.endpoints.dealer) - dealer.on('message', proxy(pub)) - log.info('DEALER socket bound on', options.endpoints.dealer) - // App/device input - let pull = zmqutil.socket('pull') - pull.bindSync(options.endpoints.pull) - pull.on('message', proxy(dealer)) - log.info('PULL socket bound on', options.endpoints.pull) - lifecycle.observe(function() { - [pub, dealer, pull].forEach(function(sock) { - try { - sock.close() - } - catch (err) { - log.error(err) - } - }) - }) -}) diff --git a/lib/units/vnc-device/index.js b/lib/units/vnc-device/index.js index 0fa366ae32..a074d3a189 100755 --- a/lib/units/vnc-device/index.js +++ b/lib/units/vnc-device/index.js @@ -4,8 +4,7 @@ import lifecycle from '../../util/lifecycle.js' import heartbeat from '../base-device/plugins/heartbeat.js' import solo from './plugins/solo.js' import info from './plugins/info/index.js' -import push from '../base-device/support/push.js' -import sub from '../base-device/support/sub.js' +import transport from '../base-device/support/transport.js' import group from './plugins/group.js' import stream from './plugins/screen/stream.js' import plogger from './plugins/logger.js' @@ -25,8 +24,7 @@ export default function(options) { .dependency(heartbeat) .dependency(solo) .dependency(info) - .dependency(push) - .dependency(sub) + .dependency(transport) .dependency(group) .dependency(stream) .define(function(options, heartbeat, solo) { diff --git a/lib/units/vnc-device/plugins/devicenotifier.js b/lib/units/vnc-device/plugins/devicenotifier.js index 44fbfa9695..c08d320486 100644 --- a/lib/units/vnc-device/plugins/devicenotifier.js +++ b/lib/units/vnc-device/plugins/devicenotifier.js @@ -3,20 +3,20 @@ import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' import logger from '../../../util/logger.js' import lifecycle from '../../../util/lifecycle.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import group from './group.js' export default syrup.serial() - .dependency(push) + .dependency(transport) .dependency(group) - .define(function(options, push, group) { + .define(function(options, transport, group) { const log = logger.createLogger('device:plugins:notifier') const notifier = {} notifier.setDeviceTemporaryUnavailable = function(err) { group.get() .then((currentGroup) => { - push.send([ + transport.send([ currentGroup.group, wireutil.envelope(new wire.TemporarilyUnavailableMessage( options.serial @@ -30,7 +30,7 @@ export default syrup.serial() notifier.setDeviceAbsent = function(err) { if (err.statusCode) { - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceStatusMessage( options.serial, @@ -39,7 +39,7 @@ export default syrup.serial() ]) } else { - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceAbsentMessage( options.serial diff --git a/lib/units/vnc-device/plugins/group.js b/lib/units/vnc-device/plugins/group.js index 11dc7d4d52..0d2e0be462 100755 --- a/lib/units/vnc-device/plugins/group.js +++ b/lib/units/vnc-device/plugins/group.js @@ -11,18 +11,16 @@ import dbapi from '../../../db/api.js' import * as apiutil from '../../../util/apiutil.js' import solo from './solo.js' import router from '../../base-device/support/router.js' -import push from '../../base-device/support/push.js' -import sub from '../../base-device/support/sub.js' +import transport from '../../base-device/support/transport.js' import channels from '../../base-device/support/channels.js' import {GroupMessage, AutoGroupMessage, UngroupMessage} from '../../../wire/wire.js' export default syrup.serial() .dependency(solo) .dependency(router) - .dependency(push) - .dependency(sub) + .dependency(transport) .dependency(channels) - .define(async(options, solo, router, push, sub, channels) => { + .define(async(options, solo, router, transport, channels) => { const log = logger.createLogger('device:plugins:group') let currentGroup = null @@ -60,7 +58,6 @@ export default syrup.serial() log.important('Now owned by "%s"', currentGroup.email) log.important('Device now in group "%s"', currentGroup.name) log.info('Rent time is ' + timeout) - log.info('Subscribing to group channel "%s"', currentGroup.group) channels.register(currentGroup.group, { timeout: timeout || options.groupTimeout, @@ -69,9 +66,7 @@ export default syrup.serial() dbapi.enhanceStatusChangedAt(options.serial, timeout) - sub.subscribe(currentGroup.group) - - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.JoinGroupMessage( options.serial @@ -95,12 +90,10 @@ export default syrup.serial() return plugin.get() .then(group => { log.important('No longer owned by "%s"', group.email) - log.info('Unsubscribing from group channel "%s"', group.group) channels.unregister(group.group) - sub.unsubscribe(group.group) - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.LeaveGroupMessage( options.serial @@ -123,19 +116,19 @@ export default syrup.serial() return plugin.join(message.owner, message.timeout, message.usage) })() .then(() =>{ - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(grouputil.RequirementMismatchError, (err) => { - push.send([ + transport.send([ channel, reply.fail(err.message) ]) }) .catch(grouputil.AlreadyGroupedError, (err) => { - push.send([ + transport.send([ channel, reply.fail(err.message) ]) @@ -156,13 +149,13 @@ export default syrup.serial() return plugin.leave('ungroup_request') })() .then(() => { - push.send([ + transport.send([ channel, reply.okay() ]) }) .catch(grouputil.NoGroupError, err => { - push.send([ + transport.send([ channel, reply.fail(err.message) ]) diff --git a/lib/units/vnc-device/plugins/heartbeat.js b/lib/units/vnc-device/plugins/heartbeat.js index f45d77aa60..ffe59b2ce7 100755 --- a/lib/units/vnc-device/plugins/heartbeat.js +++ b/lib/units/vnc-device/plugins/heartbeat.js @@ -2,13 +2,13 @@ import syrup from '@devicefarmer/stf-syrup' import lifecycle from '../../../util/lifecycle.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' export default syrup.serial() - .dependency(push) - .define((options, push) => { + .dependency(transport) + .define((options, transport) => { function beat() { - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceHeartbeatMessage( options.serial diff --git a/lib/units/vnc-device/plugins/info/index.js b/lib/units/vnc-device/plugins/info/index.js index 57e1b3333b..22194873bc 100644 --- a/lib/units/vnc-device/plugins/info/index.js +++ b/lib/units/vnc-device/plugins/info/index.js @@ -3,11 +3,11 @@ import Promise from 'bluebird' import wireutil from '../../../../wire/util.js' import wire from '../../../../wire/index.js' import logger from '../../../../util/logger.js' -import push from '../../../base-device/support/push.js' +import transport from '../../../base-device/support/transport.js' export default syrup.serial() - .dependency(push) - .define((options, push) => { + .dependency(transport) + .define((options, transport) => { const log = logger.createLogger('vnc-device:info') function manageDeviceInfo() { @@ -17,7 +17,7 @@ export default syrup.serial() let solo = wireutil.makePrivateChannel() let osName = options.deviceOs - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.InitializeIosDeviceState( options.serial @@ -41,7 +41,7 @@ export default syrup.serial() ]) log.info('Storing device type value: ' + options.deviceType) - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceTypeMessage( options.serial diff --git a/lib/units/vnc-device/plugins/logger.js b/lib/units/vnc-device/plugins/logger.js index 3b36a860e6..5beeb5a5c0 100755 --- a/lib/units/vnc-device/plugins/logger.js +++ b/lib/units/vnc-device/plugins/logger.js @@ -2,15 +2,15 @@ import syrup from '@devicefarmer/stf-syrup' import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import wireutil from '../../../wire/util.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' export default syrup.serial() - .dependency(push) - .define((options, push) => { + .dependency(transport) + .define((options, transport) => { // Forward all logs logger.on('entry', entry => { - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceLogMessage( options.serial diff --git a/lib/units/vnc-device/plugins/screen/stream.js b/lib/units/vnc-device/plugins/screen/stream.js index 9dc4d22608..5eff129a00 100755 --- a/lib/units/vnc-device/plugins/screen/stream.js +++ b/lib/units/vnc-device/plugins/screen/stream.js @@ -10,7 +10,7 @@ const logger = loggerM.createLogger('vnc-device:stream') import solo from '../solo.js' import devicenotifier from '../devicenotifier.js' -import push from '../../../base-device/support/push.js' +import transport from '../../../base-device/support/transport.js' import router from '../../../base-device/support/router.js' import group from '../group.js' import {decode} from '../../../../util/jwtutil.js' @@ -20,10 +20,10 @@ import {GestureStartMessage, GestureStopMessage, TouchDownMessage, TouchMoveMess export default syrup.serial() .dependency(solo) .dependency(devicenotifier) - .dependency(push) + .dependency(transport) .dependency(router) .dependency(group) - .define(function(options, solo, notifier, push, router, group) { + .define(function(options, solo, notifier, transport, router, group) { const wss = new WebSocket.Server({port: options.screenPort}) wss.on('connection', async function connection(ws) { @@ -99,7 +99,7 @@ export default syrup.serial() logger.info('remote screen name: ' + r.title + ' width:' + r.width + ' height: ' + r.height) width = r.width height = r.height - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.SizeIosDevice( options.serial diff --git a/lib/units/vnc-device/plugins/solo.js b/lib/units/vnc-device/plugins/solo.js index 899854eaf1..8c43c44c34 100755 --- a/lib/units/vnc-device/plugins/solo.js +++ b/lib/units/vnc-device/plugins/solo.js @@ -4,15 +4,13 @@ import logger from '../../../util/logger.js' import wire from '../../../wire/index.js' import lifecycle from '../../../util/lifecycle.js' import wireutil from '../../../wire/util.js' -import sub from '../../base-device/support/sub.js' -import push from '../../base-device/support/push.js' +import transport from '../../base-device/support/transport.js' import info from '../plugins/info/index.js' export default syrup.serial() - .dependency(sub) - .dependency(push) + .dependency(transport) .dependency(info) - .define((options, sub, push, info) => { + .define((options, transport, info) => { const log = logger.createLogger('device:plugins:solo') // The channel should keep the same value between restarts, so that @@ -25,15 +23,12 @@ export default syrup.serial() let channel = makeChannelId() - log.info('Subscribing to permanent channel "%s"', channel) - sub.subscribe(channel) - return { channel: channel, poke: () => { info.manageDeviceInfo() .then(() => { - push.send([ + transport.send([ wireutil.global, wireutil.envelope(new wire.DeviceReadyMessage( options.serial diff --git a/lib/units/websocket/index.js b/lib/units/websocket/index.js deleted file mode 100644 index 280fb188d9..0000000000 --- a/lib/units/websocket/index.js +++ /dev/null @@ -1,1412 +0,0 @@ -/** -* Copyright 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 -**/ - -import http from 'http' -import util from 'util' -import Promise from 'bluebird' -import _ from 'lodash' -import postmanRequest from 'postman-request' -import {Adb} from '@u4/adbkit' -import {v4 as uuidv4} from 'uuid' -import logger from '../../util/logger.js' -import wire from '../../wire/index.js' -import wireutil from '../../wire/util.js' -import {WireRouter} from '../../wire/router.js' -import datautil from '../../util/datautil.js' -import lifecycle from '../../util/lifecycle.js' -import cookieSession from './middleware/cookie-session.js' -import ip from './middleware/remote-ip.js' -import auth from './middleware/auth.js' -import * as jwtutil from '../../util/jwtutil.js' -import * as apiutil from '../../util/apiutil.js' -import {Server} from 'socket.io' -import db from '../../db/index.js' -import EventEmitter from 'events' -import generateToken from '../api/helpers/generateToken.js' -import { - UpdateAccessTokenMessage, - DeleteUserMessage, - DeviceChangeMessage, - UserChangeMessage, - GroupChangeMessage, - DeviceGroupChangeMessage, - GroupUserChangeMessage, - DeviceLogMessage, - DeviceIntroductionMessage, - DeviceReadyMessage, - DevicePresentMessage, - DeviceAbsentMessage, - InstalledApplications, - JoinGroupMessage, - JoinGroupByAdbFingerprintMessage, - LeaveGroupMessage, - DeviceStatusMessage, - DeviceIdentityMessage, - TransactionProgressMessage, - TransactionDoneMessage, - TransactionTreeMessage, - DeviceLogcatEntryMessage, - AirplaneModeEvent, - BatteryEvent, - GetServicesAvailabilityMessage, - DeviceBrowserMessage, - ConnectivityEvent, - PhoneStateEvent, - RotationEvent, - CapabilitiesMessage, - ReverseForwardsEvent, - TemporarilyUnavailableMessage, - UpdateRemoteConnectUrl, - KeyDownMessage, - KeyUpMessage, - KeyPressMessage -} from '../../wire/wire.js' -import AllModel from '../../db/models/all/index.js' -import UserModel from '../../db/models/user/index.js' -const request = Promise.promisifyAll(postmanRequest) -export default (async function(options) { - const log = logger.createLogger('websocket') - const server = http.createServer() - - const io = new Server(server, { - serveClient: false, - transports: ['websocket'], - pingTimeout: 60000, - pingInterval: 30000 - }) - - const channelRouter = new EventEmitter() - const { - sub - , subdev - , push - , pushdev - } = await db.createZMQSockets({...options.endpoints}, log) - await db.connect({push, pushdev, channelRouter}) - - ;[wireutil.global].forEach(function(channel) { - log.info('Subscribing to permanent webosocket channel "%s"', channel) - sub.subscribe(channel) - }) - sub.on('message', function(channel, data) { - channelRouter.emit(channel.toString(), channel, data) - }) - - io.use(cookieSession({ - name: options.ssid, - keys: [options.secret] - })) - io.use(ip({ - trust: function() { - return true - } - })) - io.use(auth({secret: options.secret})) - io.on('connection', function(socket) { - const req = socket.request - - // @ts-ignore - const {user} = req - const channels = [] - - // @ts-ignore - user.ip = socket.handshake.query.uip || req.ip - socket.emit('socket.ip', user.ip) - function joinChannel(channel) { - channels.push(channel) - log.info('Subscribing to permanent websocket joinChannel channel "%s"', channel) - channelRouter.on(channel, messageListener) - sub.subscribe(channel) - } - function leaveChannel(channel) { - _.pull(channels, channel) - channelRouter.removeListener(channel, messageListener) - sub.unsubscribe(channel) - } - - const deviceIsOwned = (channel) => user?.ownedChannels?.has(channel) - - function trySendPush(args) { - if (deviceIsOwned(args[0])) { - return push.send(args) - } - } - - function createKeyHandler(Klass) { - return function(channel, data) { - if (user?.ownedChannels?.has(channel)) { - push.send([channel, wireutil.pack(Klass, { - key: data.key - })]) - } - } - } - let disconnectSocket - var messageListener = new WireRouter() - .on(UpdateAccessTokenMessage, function() { - socket.emit('user.keys.accessToken.updated') - }) - .on(DeleteUserMessage, function() { - disconnectSocket(true) - }) - .on(DeviceChangeMessage, function(channel, message) { - if (user.groups.subscribed.indexOf(message.device.group.id) > -1) { - socket.emit('device.change', { - important: true, - data: { - serial: message.device.serial, - group: message.device.group - } - }) - } - if (user.groups.subscribed.indexOf(message.device.group.origin) > -1 || - user.groups.subscribed.indexOf(message.oldOriginGroupId) > -1) { - socket.emit('user.settings.devices.' + message.action, message) - } - }) - .on(UserChangeMessage, function(channel, message) { - Promise.map(message.targets, function(target) { - socket.emit('user.' + target + '.users.' + message.action, message) - }) - }) - .on(GroupChangeMessage, function(channel, message) { - if (user.privilege === 'admin' || - user.email === message.group.owner.email || - !apiutil.isOriginGroup(message.group.class) && - (message.isChangedDates || message.isChangedClass || message.devices.length)) { - socket.emit('user.settings.groups.' + message.action, message) - } - if (message.subscribers.indexOf(user.email) > -1) { - socket.emit('user.view.groups.' + message.action, message) - } - }) - .on(DeviceGroupChangeMessage, function(channel, message) { - if (user.groups.subscribed.indexOf(message.id) > -1) { - if (user.groups.subscribed.indexOf(message.group.id) > -1) { - socket.emit('device.updateGroupDevice', { - important: true, - data: { - serial: message.serial, - group: message.group - } - }) - } - else { - socket.emit('device.removeGroupDevices', {important: true, devices: [message.serial]}) - } - } - else if (user.groups.subscribed.indexOf(message.group.id) > -1) { - socket.emit('device.addGroupDevices', {important: true, devices: [message.serial]}) - } - }) - .on(GroupUserChangeMessage, function(channel, message) { - if (message.users.indexOf(user.email) > -1) { - if (message.isAdded) { - user.groups.subscribed = _.union(user.groups.subscribed, [message.id]) - if (message.devices.length) { - socket.emit('device.addGroupDevices', {important: true, devices: message.devices}) - } - } - else { - if (message.devices.length) { - socket.emit('device.removeGroupDevices', {important: true, devices: message.devices}) - } - if (message.isDeletedLater) { - setTimeout(function() { - user.groups.subscribed = _.without(user.groups.subscribed, message.id) - }, 5000) - } - else { - user.groups.subscribed = _.without(user.groups.subscribed, message.id) - } - } - } - }) - .on(DeviceLogMessage, function(channel, message) { - io.emit('logcat.log', message) - }) - .on(DeviceIntroductionMessage, function(channel, message) { - if (message && message.group && user.groups.subscribed.indexOf(message.group.id) > -1) { - io.emit('device.add', { - important: true, - data: { - serial: message.serial, - present: true, - provider: message.provider, - owner: null, - status: message.status, - ready: false, - reverseForwards: [], - group: message.group - } - }) - } - }) - .on(DeviceReadyMessage, function(channel, message) { - io.emit('device.change', { - important: true, - data: { - serial: message.serial, - channel: message.channel, - owner: null, // @todo Get rid of need to reset this here. - ready: true, - reverseForwards: [] // @todo Get rid of need to reset this here. - } - }) - }) - .on(DevicePresentMessage, function(channel, message) { - io.emit('device.change', { - important: true, - data: { - serial: message.serial, - present: true - } - }) - }) - .on(DeviceAbsentMessage, function(channel, message) { - io.emit('device.remove', { - important: true, - data: { - serial: message.serial, - present: false, - likelyLeaveReason: 'device_absent' - } - }) - }) - .on(InstalledApplications, function(channel, message, data) { - socket.emit('device.applications', { - important: true, - data: { - serial: message.serial, - applications: message.applications - } - }) - }) - // @TODO refactore JoimGroupMessage route - .on(JoinGroupMessage, function(channel, message) { - AllModel.getInstalledApplications({serial: message.serial}) - .then(applications => { - if (!user?.ownedChannels) { - user.ownedChannels = new Set() - } - - if (message?.owner?.email === user?.email) { - user.ownedChannels.add(applications.channel) - } - else if (user.ownedChannels.has(applications.channel)) { - user.ownedChannels.delete(applications.channel) - } - - socket.emit(`device.application-${message.serial}`, { - applications: applications - }) - socket.emit('device.change', { - important: true, - data: datautil.applyOwner({ - serial: message.serial, - owner: message.owner, - likelyLeaveReason: 'owner_change', - usage: message.usage, - applications: applications - }, user) - }) - }) - .catch(err => { - socket.emit('device.change', { - important: true, - data: datautil.applyOwner({ - serial: message.serial, - owner: message.owner, - likelyLeaveReason: 'owner_change', - usage: message.usage - }, user) - }) - }) - }) - .on(JoinGroupByAdbFingerprintMessage, function(channel, message) { - socket.emit('user.keys.adb.confirm', { - title: message.comment, - fingerprint: message.fingerprint - }) - }) - .on(LeaveGroupMessage, function(channel, message) { - io.emit('device.change', { - important: true, - data: datautil.applyOwner({ - serial: message.serial, - owner: null, - likelyLeaveReason: message.reason - }, user) - }) - }) - .on(DeviceStatusMessage, function(channel, message) { - message.likelyLeaveReason = 'status_change' - io.emit('device.change', { - important: true, - data: message - }) - }) - .on(DeviceIdentityMessage, function(channel, message) { - datautil.applyData(message) - io.emit('device.change', { - important: true, - data: message - }) - }) - .on(TransactionProgressMessage, function(channel, message) { - socket.emit('tx.progress', channel.toString(), message) - }) - .on(TransactionDoneMessage, function(channel, message) { - socket.emit('tx.done', channel.toString(), message) - }) - .on(TransactionTreeMessage, function(channel, message) { - socket.emit('tx.tree', channel.toString(), message) - }) - .on(DeviceLogcatEntryMessage, function(channel, message) { - socket.emit('logcat.entry', message) - }) - .on(AirplaneModeEvent, function(channel, message) { - io.emit('device.change', { - important: true, - data: { - serial: message.serial, - airplaneMode: message.enabled - } - }) - }) - .on(BatteryEvent, function(channel, message) { - var {serial} = message - delete message.serial - io.emit('device.change', { - important: false, - data: { - serial: serial, - battery: message - } - }) - }) - .on(GetServicesAvailabilityMessage, function(channel, message) { - let serial = message.serial - delete message.serial - io.emit('device.change', { - important: true, - data: { - serial: serial, - service: message - } - }) - }) - .on(DeviceBrowserMessage, function(channel, message) { - var {serial} = message - delete message.serial - io.emit('device.change', { - important: true, - data: datautil.applyBrowsers({ - serial: serial, - browser: message - }) - }) - }) - .on(ConnectivityEvent, function(channel, message) { - var {serial} = message - delete message.serial - io.emit('device.change', { - important: false, - data: { - serial: serial, - network: message - } - }) - }) - .on(PhoneStateEvent, function(channel, message) { - var {serial} = message - delete message.serial - io.emit('device.change', { - important: false, - data: { - serial: serial, - network: message - } - }) - }) - .on(RotationEvent, function(channel, message) { - socket.emit('device.change', { - important: false, - data: { - serial: message.serial, - display: { - rotation: message.rotation - } - } - }) - }) - .on(CapabilitiesMessage, function(channel, message) { - socket.emit('device.change', { - important: false, - data: { - serial: message.serial, - capabilities: { - hasTouch: message.hasTouch, - hasCursor: message.hasCursor - } - } - }) - }) - .on(ReverseForwardsEvent, function(channel, message) { - socket.emit('device.change', { - important: false, - data: { - serial: message.serial, - reverseForwards: message.forwards - } - }) - }) - .on(TemporarilyUnavailableMessage, function(channel, message) { - socket.emit('temporarily-unavailable', { - data: { - removeConnectUrl: message.removeConnectUrl - } - }) - }) - .on(UpdateRemoteConnectUrl, function(channel, message) { - socket.emit('device.change', { - important: true, - data: { - serial: message.serial - } - }) - }) - .handler() - channelRouter.on(wireutil.global, messageListener) - // User's private group - joinChannel(user.group) - new Promise(function(resolve) { - disconnectSocket = resolve - socket.on('disconnect', resolve) - // Global messages for all clients using socket.io - // - // Device note - .on('device.note', function(data) { - return AllModel - .setDeviceNote(data.serial, data.note) - .then(function() { - return AllModel.loadDevice(user.groups.subscribed, data.serial) - }) - .then(function(device) { - if (device) { - io.emit('device.change', { - important: true, - data: { - serial: device.serial, - notes: device.notes - } - }) - } - }) - }) - // Client specific messages - // - // Settings - .on('user.settings.update', function(data) { - if (data.alertMessage === undefined) { - UserModel.updateUserSettings(user.email, data) - } - else { - UserModel.updateUserSettings(apiutil.STF_ADMIN_EMAIL, data) - } - }) - .on('user.settings.reset', function() { - UserModel.resetUserSettings(user.email) - }) - .on('user.keys.accessToken.generate', async(data) => { - const {title} = data - const token = generateToken(user, options.secret) - await AllModel - .saveUserAccessToken(user.email, { - title: title, - id: token.id, - jwt: token.jwt - }) - socket.emit('user.keys.accessToken.generated', { - title: title, - token: token.jwt - }) - }) - .on('user.keys.accessToken.remove', function(data) { - const isAdmin = user.privilege === apiutil.ADMIN - const email = (isAdmin ? data.email : null) || user.email - return AllModel - .removeUserAccessToken(email, data.title) - .then(function() { - socket.emit('user.keys.accessToken.updated') - }) - }) - .on('user.keys.adb.add', function(data) { - // @ts-ignore - return Adb.util.parsePublicKey(data.key) - .then(function(key) { - return UserModel.lookupUsersByAdbKey(key.fingerprint) - .then(function(keys) { - return keys - }) - .then(function(users) { - if (users.length) { - throw new AllModel.DuplicateSecondaryIndexError() - } - else { - return UserModel.insertUserAdbKey(user.email, { - title: data.title, - fingerprint: key.fingerprint - }) - } - }) - .then(function() { - socket.emit('user.keys.adb.added', { - title: data.title, - fingerprint: key.fingerprint - }) - }) - }) - .then(function() { - push.send([ - wireutil.global, - wireutil.envelope(new wire.AdbKeysUpdatedMessage()) - ]) - }) - .catch(function(err) { - socket.emit('user.keys.adb.error', { - message: err.message - }) - }) - }) - .on('user.keys.adb.accept', function(data) { - return UserModel.lookupUsersByAdbKey(data.fingerprint) - .then(function(keys) { - return keys - }) - .then(function(users) { - if (users.length) { - throw new AllModel.DuplicateSecondaryIndexError() - } - else { - return UserModel.insertUserAdbKey(user.email, { - title: data.title, - fingerprint: data.fingerprint - }) - } - }) - .then(function() { - socket.emit('user.keys.adb.added', { - title: data.title, - fingerprint: data.fingerprint - }) - }) - .then(function() { - push.send([ - user.group, - wireutil.envelope(new wire.AdbKeysUpdatedMessage()) - ]) - }) - // @ts-ignore - .catch(AllModel.DuplicateSecondaryIndexError, function() { - // No-op - }) - }) - .on('user.keys.adb.remove', function(data) { - return UserModel - .deleteUserAdbKey(user.email, data.fingerprint) - .then(function() { - socket.emit('user.keys.adb.removed', data) - }) - }) - .on('shell.settings.execute', function(data) { - let command = data.command - AllModel.loadDevices().then(devices => { - devices.forEach(device => { - push.send([ - device.channel, - wireutil.envelope(new wire.ShellCommandMessage({ - command: command, - timeout: 10000 - })) - ]) - }) - }) - // TODO: support response - // joinChannel(responseChannel) - // push.send([ - // channel - // , wireutil.transaction( - // responseChannel - // , new wire.ShellCommandMessage(data) - // ) - // ]) - }) - // Touch events - .on('input.touchDown', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TouchDownMessage(data.seq, data.contact, data.x, data.y, data.pressure)) - ]) - }) - .on('input.touchMove', function(channel, data) { - try { - trySendPush([ - channel, - wireutil.envelope(new wire.TouchMoveMessage(data.seq, data.contact, data.x, data.y, data.pressure)) - ]) - } - catch (/** @type {any} */ err) { - // workaround for https://github.com/openstf/stf/issues/1180 - log.error('input.touchMove had an error', err.stack) - } - }) - .on('input.touchMoveIos', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TouchMoveIosMessage(data.toX, data.toY, data.fromX, data.fromY, data.duration || 0)) - ]) - }) - .on('tapDeviceTreeElement', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TapDeviceTreeElement(data.label)) - ]) - }) - .on('input.touchUp', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TouchUpMessage(data.seq, data.contact)) - ]) - }) - .on('input.touchCommit', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TouchCommitMessage(data.seq)) - ]) - }) - .on('input.touchReset', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TouchResetMessage(data.seq)) - ]) - }) - .on('input.gestureStart', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.GestureStartMessage(data.seq)) - ]) - }) - .on('input.gestureStop', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.GestureStopMessage(data.seq)) - ]) - }) - // Key events - .on('input.keyDown', createKeyHandler(KeyDownMessage)) - .on('input.keyUp', createKeyHandler(KeyUpMessage)) - .on('input.keyPress', createKeyHandler(KeyPressMessage)) - .on('input.type', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.TypeMessage(data.text)) - ]) - }) - .on('display.rotate', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.RotateMessage(data.rotation)) - ]) - }) - .on('quality.change', function(channel, data) { - trySendPush([ - channel, - wireutil.envelope(new wire.ChangeQualityMessage(data.quality)) - ]) - }) - // Transactions - .on('airplane.set', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.AirplaneSetMessage(data)) - ]) - }) - .on('clipboard.paste', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.PasteMessage(data.text)) - ]) - }) - .on('clipboard.copy', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.CopyMessage()) - ]) - }) - .on('clipboard.copyIos', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.CopyMessage()) - ]) - }) - .on('device.identify', function(channel, responseChannel) { - trySendPush([ - channel, - wireutil.transaction(responseChannel, new wire.PhysicalIdentifyMessage()) - ]) - }) - .on('device.reboot', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.RebootMessage()) - ]) - }) - .on('device.rebootIos', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.RebootMessage()) - ]) - }) - .on('account.check', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.AccountCheckMessage(data)) - ]) - }) - .on('account.remove', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.AccountRemoveMessage(data)) - ]) - }) - .on('account.addmenu', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.AccountAddMenuMessage()) - ]) - }) - .on('account.add', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.AccountAddMessage(data.user, data.password)) - ]) - }) - .on('account.get', function(channel, responseChannel, data) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.AccountGetMessage(data)) - ]) - }) - .on('sd.status', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.SdStatusMessage()) - ]) - }) - .on('ringer.set', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.RingerSetMessage(data.mode)) - ]) - }) - .on('ringer.get', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.RingerGetMessage()) - ]) - }) - .on('wifi.set', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.WifiSetEnabledMessage(data.enabled)) - ]) - }) - .on('wifi.get', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.WifiGetStatusMessage()) - ]) - }) - .on('bluetooth.set', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.BluetoothSetEnabledMessage(data.enabled)) - ]) - }) - .on('bluetooth.get', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.BluetoothGetStatusMessage()) - ]) - }) - .on('bluetooth.cleanBonds', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.BluetoothCleanBondedMessage()) - ]) - }) - .on('group.invite', async(channel, responseChannel, data) => { - joinChannel(responseChannel) - const keys = await UserModel.getUserAdbKeys(user.email) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GroupMessage( - new wire.OwnerMessage(user.email, user.name, user.group), - data.timeout || null, - wireutil.toDeviceRequirements(data.requirements), - null, - keys.map(key => key.fingerprint) - )) - ]) - }) - .on('group.kick', function(channel, responseChannel, data) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.UngroupMessage(wireutil.toDeviceRequirements(data.requirements))) - ]) - }) - .on('getTreeElementsIos', function(channel, responseChannel) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GetIosTreeElements()) - ]) - }) - .on('tx.cleanup', function(channel) { - leaveChannel(channel) - }) - .on('tx.punch', function(channel) { - joinChannel(channel) - socket.emit('tx.punch', channel) - }) - .on('shell.command', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ShellCommandMessage(data)) - ]) - }) - .on('shell.keepalive', function(channel, data) { - if (!deviceIsOwned(channel)) { - return - } - - push.send([ - channel, - wireutil.envelope(new wire.ShellKeepAliveMessage(data)) - ]) - }) - .on('device.install', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - const installFlags = ['-r'] - const isApi = false - joinChannel(responseChannel) - push.send([ - channel, - // @ts-ignore - wireutil.transaction(responseChannel, new wire.InstallMessage(data.href, data.launch === true, isApi, JSON.stringify(data.manifest), installFlags, req.internalJwt)) - ]) - }) - .on('device.installIos', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - const isApi = false - joinChannel(responseChannel) - push.send([ - channel, - // @ts-ignore - wireutil.transaction(responseChannel, new wire.InstallMessage(data.href, data.launch === true, isApi, JSON.stringify(data.manifest), [], req.internalJwt)) - ]) - }) - .on('device.uninstall', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.UninstallMessage(data)) - ]) - }) - .on('device.uninstallIos', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.envelope(new wire.UninstallIosMessage(data.packageName)) - ]) - }) - .on('device.launchApp', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.LaunchDeviceApp(data.pkg)) - ]) - }) - .on('device.getApps', _.debounce(async(channel, responseChannel) => { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GetInstalledApplications()) - ]) - }, 500)) - .on('app.kill', async(channel, responseChannel, data) => { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction( - responseChannel, - data?.force ? - new wire.KillDeviceApp() : - new wire.TerminateDeviceApp() - ) - ]) - }) - .on('app.getAssetList', async(channel, responseChannel) => { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GetAppAssetsList()) - ]) - }) - .on('app.getAsset', async(channel, responseChannel, data) => { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GetAppAsset(data.url)) - ]) - }) - .on('app.getAppHTML', async(channel, responseChannel) => { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GetAppHTML()) - ]) - }) - .on('app.getInspectServerUrl', async(channel, responseChannel) => { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.GetAppInspectServerUrl()) - ]) - }) - .on('device.unlockDevice', function(channel, data) { - if (!deviceIsOwned(channel)) { - return - } - - push.send([ - channel, - wireutil.envelope(new wire.UnlockDeviceMessage()) - ]) - }) - .on('storage.upload', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - request - .postAsync({ - url: util.format('%sapi/v1/resources?channel=%s', options.storageUrl, responseChannel), - json: true, - body: { - url: data.url - } - }) - .catch(function(err) { - log.error('Storage upload had an error', err.stack) - leaveChannel(responseChannel) - socket.emit('tx.cancel', responseChannel, { - success: false, - data: 'fail_upload' - }) - }) - }) - .on('forward.test', function(channel, responseChannel, data) { - joinChannel(responseChannel) - if (!data.targetHost || data.targetHost === 'localhost') { - data.targetHost = user.ip - } - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ForwardTestMessage(data)) - ]) - }) - .on('forward.create', function(channel, responseChannel, data) { - if (!data.targetHost || data.targetHost === 'localhost') { - data.targetHost = user.ip - } - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ForwardCreateMessage(data)) - ]) - }) - .on('forward.remove', function(channel, responseChannel, data) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ForwardRemoveMessage(data)) - ]) - }) - .on('logcat.start', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - // #455 and #459 - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.LogcatStartMessage(data)) - ]) - }) - .on('logcat.startIos', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.LogcatStartMessage(data)) - ]) - }) - .on('logcat.stop', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.LogcatStopMessage()) - ]) - }) - .on('logcat.stopIos', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.LogcatStopMessage()) - ]) - }) - .on('connect.start', function(channel, responseChannel) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ConnectStartMessage()) - ]) - }) - .on('connect.startIos', function(channel, responseChannel) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ConnectStartMessage()) - ]) - }) - .on('connect.stop', function(channel, responseChannel) { - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ConnectStopMessage()) - ]) - }) - .on('browser.open', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.BrowserOpenMessage(data)) - ]) - }) - .on('browser.openIos', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.BrowserOpenMessage(data)) - ]) - }) - .on('browser.clear', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.BrowserClearMessage(data)) - ]) - }) - .on('store.open', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.StoreOpenMessage()) - ]) - }) - .on('store.openIos', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.StoreOpenMessage()) - ]) - }) - .on('settings.open', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - push.send([ - channel, - wireutil.envelope(new wire.DashboardOpenMessage()) - ]) - }) - .on('screen.capture', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ScreenCaptureMessage()) - ]) - }) - .on('screen.captureIos', function(channel, responseChannel) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.ScreenCaptureMessage()) - ]) - }) - .on('fs.retrieve', function(channel, responseChannel, data) { - joinChannel(responseChannel) - push.send([ - channel, - // @ts-ignore - wireutil.transaction(responseChannel, new wire.FileSystemGetMessage(data.file, req.internalJwt)) - ]) - }) - .on('fs.list', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.FileSystemListMessage(data)) - ]) - }) - .on('fs.listIos', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - wireutil.transaction(responseChannel, new wire.FileSystemListMessage(data)) - ]) - }) - .on('fs.retrieveIos', function(channel, responseChannel, data) { - if (!deviceIsOwned(channel)) { - return - } - - joinChannel(responseChannel) - push.send([ - channel, - // @ts-ignore - wireutil.transaction(responseChannel, new wire.FileSystemGetMessage(data.file, req.internalJwt)) - ]) - }) - .on('policy.accept', function(data) { - UserModel.acceptPolicy(user.email) - }) - }) - .finally(function() { - // Clean up all listeners and subscriptions - channelRouter.removeListener(wireutil.global, messageListener) - channels.forEach(function(channel) { - channelRouter.removeListener(channel, messageListener) - sub.unsubscribe(channel) - }) - socket.disconnect(true) - }) - .catch(function(err) { - log.error('Client had an error, disconnecting due to probable loss of integrity', err.stack) - }) - }) - lifecycle.observe(function() { - [push, pushdev, sub, subdev].forEach(function(sock) { - try { - sock.close() - } - catch (err) { - // No-op - } - }) - }) - server.listen(options.port) - log.info('Listening on port websockets %d', options.port) -}) diff --git a/lib/units/websocket/index.ts b/lib/units/websocket/index.ts new file mode 100644 index 0000000000..93118d2d81 --- /dev/null +++ b/lib/units/websocket/index.ts @@ -0,0 +1,964 @@ +/** +* Copyright 2019 contains code contributed by Orange SA, authors: Denis Barbaron - Licensed under the Apache license 2.0 +**/ + +import http from 'http' +import _ from 'lodash' +import {Adb} from '@u4/adbkit' +import logger from '../../util/logger.js' +import wireutil from '../../wire/util.js' +import {WireRouter} from '../../wire/router.js' +import datautil from '../../util/datautil.js' +import lifecycle from '../../util/lifecycle.js' +import srv from '../../util/srv.js' +import cookieSession from './middleware/cookieSession.js' +import ip from './middleware/remoteIp.js' +import auth from './middleware/auth.js' +import * as apiutil from '../../util/apiutil.js' +import {Server} from 'socket.io' +import db from '../../db/index.js' +import dbapi from '../../db/api.js' +import generateToken from '../api/helpers/generateToken.js' +import {DealerSocket} from '../../util/zmqsocket.js' +import {AppTransport} from '../../wire/app-transport.js' +import {TransactionManager} from '../../wire/transmanager.js' +import {ClientDispatcher} from './support/clientDispatcher.js' +import {DeviceOwnership} from './support/deviceOwnership.js' +import {OwnershipCache} from './support/ownershipCache.js' +import { + UpdateAccessTokenMessage, + DeleteUserMessage, + DeviceChangeMessage, + UserChangeMessage, + GroupChangeMessage, + DeviceGroupChangeMessage, + GroupUserChangeMessage, + DeviceLogMessage, + DeviceIntroductionMessage, + DeviceReadyMessage, + DevicePresentMessage, + DeviceAbsentMessage, + InstalledApplications, + JoinGroupMessage, + JoinGroupByAdbFingerprintMessage, + LeaveGroupMessage, + DeviceStatusMessage, + DeviceIdentityMessage, + DeviceLogcatEntryMessage, + ShellCommandMessage, + AirplaneModeEvent, + BatteryEvent, + GetServicesAvailabilityMessage, + DeviceBrowserMessage, + ConnectivityEvent, + PhoneStateEvent, + RotationEvent, + CapabilitiesMessage, + + TemporarilyUnavailableMessage, + UpdateRemoteConnectUrl, + KeyDownMessage, + KeyUpMessage, + KeyPressMessage, + TouchDownMessage, + TouchMoveMessage, + TouchMoveIosMessage, + TouchUpMessage, + TouchCommitMessage, + TouchResetMessage, + GestureStartMessage, + GestureStopMessage, + TypeMessage, + TapDeviceTreeElement, + RotateMessage, + ChangeQualityMessage, + AdbKeysUpdatedMessage, + ShellKeepAliveMessage, + UninstallIosMessage, + UnlockDeviceMessage, + DashboardOpenMessage, + AirplaneSetMessage, + PasteMessage, + CopyMessage, + PhysicalIdentifyMessage, + RebootMessage, + AccountCheckMessage, + AccountRemoveMessage, + AccountAddMenuMessage, + AccountAddMessage, + AccountGetMessage, + SdStatusMessage, + RingerSetMessage, + RingerGetMessage, + WifiSetEnabledMessage, + WifiGetStatusMessage, + BluetoothSetEnabledMessage, + BluetoothGetStatusMessage, + BluetoothCleanBondedMessage, + GroupMessage, + UngroupMessage, + GetIosTreeElements, + InstallMessage, + UninstallMessage, + LaunchDeviceApp, + GetInstalledApplications, + KillDeviceApp, + TerminateDeviceApp, + GetAppAssetsList, + GetAppAsset, + GetAppHTML, + GetAppInspectServerUrl, + + LogcatStartMessage, + LogcatStopMessage, + ConnectStartMessage, + ConnectStopMessage, + BrowserOpenMessage, + BrowserClearMessage, + StoreOpenMessage, + ScreenCaptureMessage, + FileSystemGetMessage, + FileSystemListMessage, + SizeIosDevice +} from '../../wire/wire.js' +import AllModel from '../../db/models/all/index.js' +import UserModel from '../../db/models/user/index.js' +import type {MessageType} from '@protobuf-ts/runtime' + +interface Options { + port: number + secret: string + ssid: string + storageUrl: string + endpoints: { + proxy: string[] + } +} + +export default (async (options: Options) => { + const log = logger.createLogger('websocket') + const server = http.createServer() + + const io = new Server(server, { + serveClient: false, + transports: ['websocket'], + pingTimeout: 60000, + pingInterval: 30000 + }) + + // One DEALER to the proxy. AppTransport wraps it; TransactionManager owns + // request/reply correlation with optional progress streaming. + const dealer = new DealerSocket({probeRouter: true}) + try { + await Promise.all(options.endpoints.proxy.map((endpoint) => + srv.resolve(endpoint).then((records) => + srv.attempt(records, (record) => { + log.info('Sending to proxy "%s"', record.url) + dealer.connect(record.url) + return Promise.resolve(true) + }) + ) + )) + } + catch (err: any) { + log.fatal('Unable to connect to proxy endpoint: %s', (err && err.message) || err) + return lifecycle.fatal() + } + + const transport = new AppTransport(dealer) + const txmanager = new TransactionManager(transport) + await db.connect() + + // Short-lived cache: collapses burst of reconnects for the same user into + // one DB query. TTL 10 s is enough to cover a page-refresh burst while + // staying fresh for normal group changes. + const ownedCache = new OwnershipCache(10_000) + + // Resolve provider.name from Mongo for a given serial. + const resolveProvider = async (serial: string): Promise => { + try { + const device = await dbapi.loadDeviceBySerial(serial) + return device?.provider?.name + } + catch (err: any) { + log.warn('Could not resolve provider for %s: %s', serial, err?.message) + return undefined + } + } + + // One WireRouter decodes each inbound broadcast once and dispatches the + // One ClientDispatcher decodes each inbound broadcast once and dispatches the + // decoded message to every connection's per-type handler. + const hub = new ClientDispatcher() + const route = new WireRouter() + .on(UpdateAccessTokenMessage, (ch, m) => hub.dispatch('UpdateAccessTokenMessage', ch, m)) + .on(DeleteUserMessage, (ch, m) => hub.dispatch('DeleteUserMessage', ch, m)) + .on(DeviceChangeMessage, (ch, m) => hub.dispatch('DeviceChangeMessage', ch, m)) + .on(UserChangeMessage, (ch, m) => hub.dispatch('UserChangeMessage', ch, m)) + .on(GroupChangeMessage, (ch, m) => hub.dispatch('GroupChangeMessage', ch, m)) + .on(DeviceGroupChangeMessage, (ch, m) => hub.dispatch('DeviceGroupChangeMessage', ch, m)) + .on(GroupUserChangeMessage, (ch, m) => hub.dispatch('GroupUserChangeMessage', ch, m)) + .on(DeviceLogMessage, (ch, m) => hub.dispatch('DeviceLogMessage', ch, m)) + .on(DeviceIntroductionMessage, (ch, m) => hub.dispatch('DeviceIntroductionMessage', ch, m)) + .on(DeviceReadyMessage, (ch, m) => hub.dispatch('DeviceReadyMessage', ch, m)) + .on(DevicePresentMessage, (ch, m) => hub.dispatch('DevicePresentMessage', ch, m)) + .on(DeviceAbsentMessage, (ch, m) => hub.dispatch('DeviceAbsentMessage', ch, m)) + .on(InstalledApplications, (ch, m) => hub.dispatch('InstalledApplications', ch, m)) + .on(JoinGroupMessage, (ch, m) => hub.dispatch('JoinGroupMessage', ch, m)) + .on(JoinGroupByAdbFingerprintMessage, (ch, m) => hub.dispatch('JoinGroupByAdbFingerprintMessage', ch, m)) + .on(LeaveGroupMessage, (ch, m) => hub.dispatch('LeaveGroupMessage', ch, m)) + .on(DeviceStatusMessage, (ch, m) => hub.dispatch('DeviceStatusMessage', ch, m)) + .on(DeviceIdentityMessage, (ch, m) => hub.dispatch('DeviceIdentityMessage', ch, m)) + .on(SizeIosDevice, (ch, m) => hub.dispatch('SizeIosDevice', ch, m)) + .on(DeviceLogcatEntryMessage, (ch, m) => hub.dispatch('DeviceLogcatEntryMessage', ch, m)) + .on(AirplaneModeEvent, (ch, m) => hub.dispatch('AirplaneModeEvent', ch, m)) + .on(BatteryEvent, (ch, m) => hub.dispatch('BatteryEvent', ch, m)) + .on(GetServicesAvailabilityMessage, (ch, m) => hub.dispatch('GetServicesAvailabilityMessage', ch, m)) + .on(DeviceBrowserMessage, (ch, m) => hub.dispatch('DeviceBrowserMessage', ch, m)) + .on(ConnectivityEvent, (ch, m) => hub.dispatch('ConnectivityEvent', ch, m)) + .on(PhoneStateEvent, (ch, m) => hub.dispatch('PhoneStateEvent', ch, m)) + .on(RotationEvent, (ch, m) => hub.dispatch('RotationEvent', ch, m)) + .on(CapabilitiesMessage, (ch, m) => hub.dispatch('CapabilitiesMessage', ch, m)) + .on(TemporarilyUnavailableMessage, (ch, m) => hub.dispatch('TemporarilyUnavailableMessage', ch, m)) + .on(UpdateRemoteConnectUrl, (ch, m) => hub.dispatch('UpdateRemoteConnectUrl', ch, m)) + .handler() + + transport.on('broadcast', (body: Buffer) => route('', body)) + transport.registerBroadcast() + + io.use(cookieSession({ + name: options.ssid, + keys: [options.secret] + })) + io.use(ip({ + trust: () => true + })) + io.use(auth({secret: options.secret})) + + io.on('connection', (socket) => { + const req = socket.request as any + const user = req.user + const ownership = new DeviceOwnership() + + user.ip = socket.handshake.query.uip || req.ip + socket.emit('socket.ip', user.ip) + + // Seed ownership from DB so that touch/commands work immediately after + // a page refresh (ownership is otherwise empty until the next + // JoinGroupMessage broadcast, which the device won't re-send if it's + // already in the group). + ownedCache.get(user.email, async () => { + const devices = await dbapi.loadUserDevices(user.email) + return devices + .filter((d: any) => d.provider?.name) + .map((d: any) => ({serial: d.serial, providerName: d.provider.name})) + }).then((owned) => { + for (const {serial, providerName} of owned) { + ownership.claim(serial, providerName) + } + }).catch(() => {/* non-fatal: ownership stays empty, next JoinGroup will fix it */}) + + // Resolves provider.name for a serial, checking the ownership cache first. + const providerFor = async (serial: string): Promise => { + const cached = ownership.providerOf(serial) + if (cached) { + return cached + } + const providerName = await resolveProvider(serial) + if (providerName) { + ownership.rememberProvider(serial, providerName) + } + return providerName + } + + // Fire-and-forget command to the owned device. + const sendOwned = async (serial: string, envelope: Uint8Array) => { + if (!ownership.isOwned(serial)) { + return + } + const providerName = await providerFor(serial) + if (!providerName) { + return + } + transport.sendCommand(providerName, serial, envelope) + } + + // Runs a device transaction and relays progress/result to the client's + // response channel via tx.progress / tx.done events. + const runTx = async ( + serial: string, + responseChannel: string, + messageType: MessageType, + message: T, + {timeout, requireOwned = true}: {timeout?: number; requireOwned?: boolean} = {} + ) => { + if (requireOwned && !ownership.isOwned(serial)) { + return + } + const providerName = await providerFor(serial) + if (!providerName) { + socket.emit('tx.done', responseChannel, {success: false, data: 'no_provider'}) + return + } + try { + const result = await txmanager.runTransaction(providerName, serial, messageType, message, { + timeout, + onProgress: (data, progress, seq) => + socket.emit('tx.progress', responseChannel, {source: serial, seq, data, progress}) + }) + socket.emit('tx.done', responseChannel, { + source: serial, + success: result.success, + data: result.data, + body: result.body && Object.keys(result.body).length ? JSON.stringify(result.body) : undefined + }) + } + catch (err: any) { + socket.emit('tx.done', responseChannel, { + source: serial, + success: false, + data: (err && err.data) || 'fail' + }) + } + } + + const createKeyHandler = (Klass: MessageType) => + (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(Klass, {key: data.key})).catch(() => {}) + } + + let disconnectSocket!: (value?: any) => void + + // Per-connection broadcast handler map registered into the hub. + const handlers = { + UpdateAccessTokenMessage: () => { + socket.emit('user.keys.accessToken.updated') + }, + // Only disconnect the socket of the user that was actually deleted. + DeleteUserMessage: (_ch: string, message: any) => { + if (message.email === user.email) { + disconnectSocket(true) + } + }, + DeviceChangeMessage: (_ch: string, message: any) => { + if (user.groups.subscribed.indexOf(message.device.group.id) > -1) { + socket.emit('device.change', { + important: true, + data: { + serial: message.device.serial, + group: message.device.group + } + }) + } + if (user.groups.subscribed.indexOf(message.device.group.origin) > -1 || + user.groups.subscribed.indexOf(message.oldOriginGroupId) > -1) { + socket.emit('user.settings.devices.' + message.action, message) + } + }, + UserChangeMessage: (_ch: string, message: any) => { + message.targets.forEach((target: any) => { + socket.emit('user.' + target + '.users.' + message.action, message) + }) + }, + GroupChangeMessage: (_ch: string, message: any) => { + if (user.privilege === 'admin' || + user.email === message.group.owner.email || + !apiutil.isOriginGroup(message.group.class) && + (message.isChangedDates || message.isChangedClass || message.devices.length)) { + socket.emit('user.settings.groups.' + message.action, message) + } + if (message.subscribers.indexOf(user.email) > -1) { + socket.emit('user.view.groups.' + message.action, message) + } + }, + DeviceGroupChangeMessage: (_ch: string, message: any) => { + if (user.groups.subscribed.indexOf(message.id) > -1) { + if (user.groups.subscribed.indexOf(message.group.id) > -1) { + socket.emit('device.updateGroupDevice', { + important: true, + data: { + serial: message.serial, + group: message.group + } + }) + } + else { + socket.emit('device.removeGroupDevices', {important: true, devices: [message.serial]}) + } + } + else if (user.groups.subscribed.indexOf(message.group.id) > -1) { + socket.emit('device.addGroupDevices', {important: true, devices: [message.serial]}) + } + }, + GroupUserChangeMessage: (_ch: string, message: any) => { + if (message.users.indexOf(user.email) > -1) { + if (message.isAdded) { + user.groups.subscribed = [...new Set([...user.groups.subscribed, message.id])] + if (message.devices.length) { + socket.emit('device.addGroupDevices', {important: true, devices: message.devices}) + } + } + else { + if (message.devices.length) { + socket.emit('device.removeGroupDevices', {important: true, devices: message.devices}) + } + if (message.isDeletedLater) { + setTimeout(() => { + user.groups.subscribed = user.groups.subscribed.filter((v: string) => v !== message.id) + }, 5000) + } + else { + user.groups.subscribed = user.groups.subscribed.filter((v: string) => v !== message.id) + } + } + } + }, + DeviceLogMessage: (_ch: string, message: any) => { + io.emit('logcat.log', message) + }, + DeviceIntroductionMessage: (_ch: string, message: any) => { + if (message && message.group && user.groups.subscribed.indexOf(message.group.id) > -1) { + io.emit('device.add', { + important: true, + data: { + serial: message.serial, + present: true, + provider: message.provider, + owner: null, + status: message.status, + ready: false, + reverseForwards: [], + group: message.group + } + }) + } + }, + DeviceReadyMessage: (_ch: string, message: any) => { + io.emit('device.change', { + important: true, + data: { + serial: message.serial, + channel: message.channel, + owner: null, + ready: true, + reverseForwards: [], + } + }) + }, + DevicePresentMessage: (_ch: string, message: any) => { + io.emit('device.change', { + important: true, + data: { + serial: message.serial, + present: true + } + }) + }, + DeviceAbsentMessage: (_ch: string, message: any) => { + ownership.release(message.serial) + io.emit('device.remove', { + important: true, + data: { + serial: message.serial, + present: false, + likelyLeaveReason: 'device_absent' + } + }) + }, + InstalledApplications: (_ch: string, message: any) => { + socket.emit('device.applications', { + important: true, + data: { + serial: message.serial, + applications: message.applications + } + }) + }, + DeviceLogcatEntryMessage: (_ch: string, message: any) => { + socket.emit('logcat.entry', message) + }, + JoinGroupMessage: (_ch: string, message: any) => { + // Track ownership by serial (provider resolved lazily on first command). + if (message.owner && message.owner.email === user.email) { + ownership.claim(message.serial, ownership.providerOf(message.serial) ?? '') + providerFor(message.serial).catch(() => {}) + } + + AllModel.getInstalledApplications({serial: message.serial}) + .then((applications: any) => { + socket.emit(`device.application-${message.serial}`, {applications}) + socket.emit('device.change', { + important: true, + data: datautil.applyOwner({ + serial: message.serial, + owner: message.owner, + likelyLeaveReason: 'owner_change', + usage: message.usage, + applications + }, user) + }) + }) + .catch(() => { + socket.emit('device.change', { + important: true, + data: datautil.applyOwner({ + serial: message.serial, + owner: message.owner, + likelyLeaveReason: 'owner_change', + usage: message.usage + }, user) + }) + }) + }, + JoinGroupByAdbFingerprintMessage: (_ch: string, message: any) => { + socket.emit('user.keys.adb.confirm', { + title: message.comment, + fingerprint: message.fingerprint + }) + }, + LeaveGroupMessage: (_ch: string, message: any) => { + ownership.release(message.serial) + ownedCache.invalidate(user.email) + io.emit('device.change', { + important: true, + data: datautil.applyOwner({ + serial: message.serial, + owner: null, + likelyLeaveReason: message.reason + }, user) + }) + }, + DeviceStatusMessage: (_ch: string, message: any) => { + message.likelyLeaveReason = 'status_change' + io.emit('device.change', {important: true, data: message}) + }, + DeviceIdentityMessage: (_ch: string, message: any) => { + datautil.applyData(message) + io.emit('device.change', {important: true, data: message}) + }, + SizeIosDevice: (_ch: string, message: any) => { + io.emit('device.change', { + important: true, + data: { + serial: message.id, + display: { + width: message.width, + height: message.height, + scale: message.scale, + url: message.url + } + } + }) + }, + AirplaneModeEvent: (_ch: string, message: any) => { + io.emit('device.change', { + important: true, + data: {serial: message.serial, airplaneMode: message.enabled} + }) + }, + BatteryEvent: (_ch: string, message: any) => { + const {serial} = message + delete message.serial + io.emit('device.change', {important: false, data: {serial, battery: message}}) + }, + GetServicesAvailabilityMessage: (_ch: string, message: any) => { + const serial = message.serial + delete message.serial + io.emit('device.change', {important: true, data: {serial, service: message}}) + }, + DeviceBrowserMessage: (_ch: string, message: any) => { + const {serial} = message + delete message.serial + io.emit('device.change', { + important: true, + data: datautil.applyBrowsers({serial, browser: message}) + }) + }, + ConnectivityEvent: (_ch: string, message: any) => { + const {serial} = message + delete message.serial + io.emit('device.change', {important: false, data: {serial, network: message}}) + }, + PhoneStateEvent: (_ch: string, message: any) => { + const {serial} = message + delete message.serial + io.emit('device.change', {important: false, data: {serial, network: message}}) + }, + RotationEvent: (_ch: string, message: any) => { + socket.emit('device.change', { + important: false, + data: {serial: message.serial, display: {rotation: message.rotation}} + }) + }, + CapabilitiesMessage: (_ch: string, message: any) => { + socket.emit('device.change', { + important: false, + data: { + serial: message.serial, + capabilities: {hasTouch: message.hasTouch, hasCursor: message.hasCursor} + } + }) + }, + TemporarilyUnavailableMessage: (_ch: string, message: any) => { + socket.emit('temporarily-unavailable', { + data: {removeConnectUrl: message.removeConnectUrl} + }) + }, + UpdateRemoteConnectUrl: (_ch: string, message: any) => { + socket.emit('device.change', {important: true, data: {serial: message.serial}}) + } + } + + hub.add(socket.id, handlers) + + new Promise((resolve) => { + disconnectSocket = resolve + socket.on('disconnect', () => resolve()) + + socket.on('device.note', async (data: any) => { + await AllModel.setDeviceNote(data.serial, data.note) + const device = await AllModel.loadDevice(user.groups.subscribed, data.serial) + if (device) { + io.emit('device.change', { + important: true, + data: {serial: device.serial, notes: device.notes} + }) + } + }) + + socket.on('user.settings.update', (data: any) => { + if (data.alertMessage === undefined) { + UserModel.updateUserSettings(user.email, data) + } + else { + UserModel.updateUserSettings(apiutil.STF_ADMIN_EMAIL, data) + } + }) + + socket.on('user.settings.reset', () => { + UserModel.resetUserSettings(user.email) + }) + + socket.on('user.keys.accessToken.generate', async (data: any) => { + const {title} = data + const token = generateToken(user, options.secret) + await AllModel.saveUserAccessToken(user.email, { + title, + id: token.id, + jwt: token.jwt + }) + socket.emit('user.keys.accessToken.generated', {title, token: token.jwt}) + }) + + socket.on('user.keys.accessToken.remove', async (data: any) => { + const isAdmin = user.privilege === apiutil.ADMIN + const email = (isAdmin ? data.email : null) || user.email + await AllModel.removeUserAccessToken(email, data.title) + socket.emit('user.keys.accessToken.updated') + }) + + socket.on('user.keys.adb.add', async (data: any) => { + try { + const key = await Adb.util.parsePublicKey(data.key) + const users = await UserModel.lookupUsersByAdbKey(key.fingerprint) + if (users.length) { + throw new AllModel.DuplicateSecondaryIndexError() + } + await UserModel.insertUserAdbKey(user.email, { + title: data.title, + fingerprint: key.fingerprint + }) + socket.emit('user.keys.adb.added', {title: data.title, fingerprint: key.fingerprint}) + transport.sendBroadcast(wireutil.pack(AdbKeysUpdatedMessage, {})) + } + catch (err: any) { + socket.emit('user.keys.adb.error', {message: err.message}) + } + }) + + socket.on('user.keys.adb.accept', async (data: any) => { + try { + const users = await UserModel.lookupUsersByAdbKey(data.fingerprint) + if (users.length) { + throw new AllModel.DuplicateSecondaryIndexError() + } + await UserModel.insertUserAdbKey(user.email, { + title: data.title, + fingerprint: data.fingerprint + }) + socket.emit('user.keys.adb.added', {title: data.title, fingerprint: data.fingerprint}) + transport.sendBroadcast(wireutil.pack(AdbKeysUpdatedMessage, {})) + } + catch (err: any) { + if (!(err instanceof AllModel.DuplicateSecondaryIndexError)) throw err + } + }) + + socket.on('user.keys.adb.remove', async (data: any) => { + await UserModel.deleteUserAdbKey(user.email, data.fingerprint) + socket.emit('user.keys.adb.removed', data) + }) + + socket.on('shell.settings.execute', async (data: any) => { + if (user.privilege !== apiutil.ADMIN) { + return + } + const {command} = data + const devices = await AllModel.loadDevices() + devices.forEach((device: any) => { + if (device.provider?.name) { + transport.sendCommand(device.provider.name, device.serial, + wireutil.pack(ShellCommandMessage, {command, timeout: 10000})) + } + }) + }) + + // Touch / input events (fire-and-forget to the owned device). + socket.on('input.touchDown', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TouchDownMessage, { + seq: data.seq, contact: data.contact, x: data.x, y: data.y, pressure: data.pressure + })).catch(() => {}) + }) + socket.on('input.touchMove', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TouchMoveMessage, { + seq: data.seq, contact: data.contact, x: data.x, y: data.y, pressure: data.pressure + })).catch(() => {}) + }) + socket.on('input.touchMoveIos', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TouchMoveIosMessage, { + toX: data.toX, toY: data.toY, fromX: data.fromX, fromY: data.fromY, duration: data.duration || 0 + })).catch(() => {}) + }) + socket.on('tapDeviceTreeElement', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TapDeviceTreeElement, {label: data.label})).catch(() => {}) + }) + socket.on('input.touchUp', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TouchUpMessage, {seq: data.seq, contact: data.contact})).catch(() => {}) + }) + socket.on('input.touchCommit', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TouchCommitMessage, {seq: data.seq})).catch(() => {}) + }) + socket.on('input.touchReset', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TouchResetMessage, {seq: data.seq})).catch(() => {}) + }) + socket.on('input.gestureStart', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(GestureStartMessage, {seq: data.seq})).catch(() => {}) + }) + socket.on('input.gestureStop', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(GestureStopMessage, {seq: data.seq})).catch(() => {}) + }) + + socket.on('input.keyDown', createKeyHandler(KeyDownMessage)) + socket.on('input.keyUp', createKeyHandler(KeyUpMessage)) + socket.on('input.keyPress', createKeyHandler(KeyPressMessage)) + + socket.on('input.type', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(TypeMessage, {text: data.text})).catch(() => {}) + }) + socket.on('display.rotate', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(RotateMessage, {rotation: data.rotation})).catch(() => {}) + }) + socket.on('quality.change', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(ChangeQualityMessage, {quality: data.quality})).catch(() => {}) + }) + + // Transactions. Each takes (serial, responseChannel, [data]). + socket.on('airplane.set', (serial: string, rc: string, data: any) => + runTx(serial, rc, AirplaneSetMessage, {enabled: data.enabled})) + socket.on('clipboard.paste', (serial: string, rc: string, data: any) => + runTx(serial, rc, PasteMessage, {text: data.text})) + socket.on('clipboard.copy', (serial: string, rc: string) => + runTx(serial, rc, CopyMessage, {})) + socket.on('clipboard.copyIos', (serial: string, rc: string) => + runTx(serial, rc, CopyMessage, {})) + socket.on('device.identify', (serial: string, rc: string) => + runTx(serial, rc, PhysicalIdentifyMessage, {}, {requireOwned: false})) + socket.on('device.reboot', (serial: string, rc: string) => + runTx(serial, rc, RebootMessage, {})) + socket.on('device.rebootIos', (serial: string, rc: string) => + runTx(serial, rc, RebootMessage, {})) + socket.on('account.check', (serial: string, rc: string, data: any) => + runTx(serial, rc, AccountCheckMessage, {type: data.type, account: data.account})) + socket.on('account.remove', (serial: string, rc: string, data: any) => + runTx(serial, rc, AccountRemoveMessage, {type: data.type, account: data.account})) + socket.on('account.addmenu', (serial: string, rc: string) => + runTx(serial, rc, AccountAddMenuMessage, {})) + socket.on('account.add', (serial: string, rc: string, data: any) => + runTx(serial, rc, AccountAddMessage, {user: data.user, password: data.password})) + socket.on('account.get', (serial: string, rc: string, data: any) => + runTx(serial, rc, AccountGetMessage, {type: data.type}, {requireOwned: false})) + socket.on('sd.status', (serial: string, rc: string) => + runTx(serial, rc, SdStatusMessage, {})) + socket.on('ringer.set', (serial: string, rc: string, data: any) => + runTx(serial, rc, RingerSetMessage, {mode: data.mode})) + socket.on('ringer.get', (serial: string, rc: string) => + runTx(serial, rc, RingerGetMessage, {})) + socket.on('wifi.set', (serial: string, rc: string, data: any) => + runTx(serial, rc, WifiSetEnabledMessage, {enabled: data.enabled})) + socket.on('wifi.get', (serial: string, rc: string) => + runTx(serial, rc, WifiGetStatusMessage, {})) + socket.on('bluetooth.set', (serial: string, rc: string, data: any) => + runTx(serial, rc, BluetoothSetEnabledMessage, {enabled: data.enabled})) + socket.on('bluetooth.get', (serial: string, rc: string) => + runTx(serial, rc, BluetoothGetStatusMessage, {})) + socket.on('bluetooth.cleanBonds', (serial: string, rc: string) => + runTx(serial, rc, BluetoothCleanBondedMessage, {})) + + socket.on('group.invite', async (serial: string, rc: string, data: any) => { + const keys = await UserModel.getUserAdbKeys(user.email) + runTx(serial, rc, GroupMessage, { + owner: {email: user.email, name: user.name, group: user.group}, + timeout: data.timeout || undefined, + requirements: wireutil.toDeviceRequirements(data.requirements), + keys: keys.map((key: any) => key.fingerprint) + }, {requireOwned: false}) + }) + socket.on('group.kick', (serial: string, rc: string, data: any) => + runTx(serial, rc, UngroupMessage, { + requirements: wireutil.toDeviceRequirements(data.requirements) + }, {requireOwned: false})) + + socket.on('getTreeElementsIos', (serial: string, rc: string) => + runTx(serial, rc, GetIosTreeElements, {}, {requireOwned: false})) + + socket.on('shell.command', (serial: string, rc: string, data: any) => + runTx(serial, rc, ShellCommandMessage, {command: data.command, timeout: data.timeout})) + + socket.on('shell.keepalive', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(ShellKeepAliveMessage, {timeout: data.timeout})).catch(() => {}) + }) + + socket.on('device.install', (serial: string, rc: string, data: any) => + runTx(serial, rc, InstallMessage, { + href: data.href, + launch: data.launch === true, + isApi: false, + manifest: JSON.stringify(data.manifest), + installFlags: ['-r'], + jwt: req.internalJwt + })) + socket.on('device.installIos', (serial: string, rc: string, data: any) => + runTx(serial, rc, InstallMessage, { + href: data.href, + launch: data.launch === true, + isApi: false, + manifest: JSON.stringify(data.manifest), + installFlags: [], + jwt: req.internalJwt + })) + socket.on('device.uninstall', (serial: string, rc: string, data: any) => + runTx(serial, rc, UninstallMessage, {packageName: data.packageName})) + socket.on('device.uninstallIos', (serial: string, data: any) => { + sendOwned(serial, wireutil.pack(UninstallIosMessage, {packageName: data.packageName})).catch(() => {}) + }) + socket.on('device.launchApp', (serial: string, rc: string, data: any) => + runTx(serial, rc, LaunchDeviceApp, {pkg: data.pkg})) + + socket.on('device.unlockDevice', (serial: string) => { + sendOwned(serial, wireutil.pack(UnlockDeviceMessage, {})).catch(() => {}) + }) + + const getApps = (serial: string, rc: string) => + runTx(serial, rc, GetInstalledApplications, {}) + // Preserve the original debounce on the app list fetch. + socket.on('device.getApps', _.debounce(getApps, 500)) + + socket.on('app.kill', (serial: string, rc: string, data: any) => + runTx(serial, rc, data?.force ? KillDeviceApp : TerminateDeviceApp, {})) + socket.on('app.getAssetList', (serial: string, rc: string) => + runTx(serial, rc, GetAppAssetsList, {})) + socket.on('app.getAsset', (serial: string, rc: string, data: any) => + runTx(serial, rc, GetAppAsset, {url: data.url})) + socket.on('app.getAppHTML', (serial: string, rc: string) => + runTx(serial, rc, GetAppHTML, {})) + socket.on('app.getInspectServerUrl', (serial: string, rc: string) => + runTx(serial, rc, GetAppInspectServerUrl, {})) + + socket.on('storage.upload', async (serial: string, rc: string, data: any) => { + if (!ownership.isOwned(serial)) { + return + } + try { + await fetch(`${options.storageUrl}api/v1/resources?channel=${rc}`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({url: data.url}) + }) + } + catch (err: any) { + log.error('Storage upload had an error: %s', err.stack) + socket.emit('tx.cancel', rc, {success: false, data: 'fail_upload'}) + } + }) + + socket.on('logcat.start', (serial: string, rc: string, data: any) => + runTx(serial, rc, LogcatStartMessage, {filters: data.filters})) + socket.on('logcat.startIos', (serial: string, rc: string, data: any) => + runTx(serial, rc, LogcatStartMessage, {filters: data.filters})) + socket.on('logcat.stop', (serial: string, rc: string) => + runTx(serial, rc, LogcatStopMessage, {})) + socket.on('logcat.stopIos', (serial: string, rc: string) => + runTx(serial, rc, LogcatStopMessage, {})) + + socket.on('connect.start', (serial: string, rc: string) => + runTx(serial, rc, ConnectStartMessage, {}, {requireOwned: false})) + socket.on('connect.startIos', (serial: string, rc: string) => + runTx(serial, rc, ConnectStartMessage, {}, {requireOwned: false})) + socket.on('connect.stop', (serial: string, rc: string) => + runTx(serial, rc, ConnectStopMessage, {}, {requireOwned: false})) + + socket.on('browser.open', (serial: string, rc: string, data: any) => + runTx(serial, rc, BrowserOpenMessage, {url: data.url, browser: data.browser})) + socket.on('browser.openIos', (serial: string, rc: string, data: any) => + runTx(serial, rc, BrowserOpenMessage, {url: data.url, browser: data.browser})) + socket.on('browser.clear', (serial: string, rc: string, data: any) => + runTx(serial, rc, BrowserClearMessage, {browser: data.browser})) + + socket.on('store.open', (serial: string, rc: string) => + runTx(serial, rc, StoreOpenMessage, {})) + socket.on('store.openIos', (serial: string, rc: string) => + runTx(serial, rc, StoreOpenMessage, {})) + + socket.on('settings.open', (serial: string) => { + sendOwned(serial, wireutil.pack(DashboardOpenMessage, {})).catch(() => {}) + }) + + socket.on('screen.capture', (serial: string, rc: string) => + runTx(serial, rc, ScreenCaptureMessage, {} as any)) + socket.on('screen.captureIos', (serial: string, rc: string) => + runTx(serial, rc, ScreenCaptureMessage, {} as any)) + + socket.on('fs.retrieve', (serial: string, rc: string, data: any) => + runTx(serial, rc, FileSystemGetMessage, {file: data.file, jwt: req.internalJwt}, {requireOwned: false})) + socket.on('fs.list', (serial: string, rc: string, data: any) => + runTx(serial, rc, FileSystemListMessage, {dir: data.dir})) + socket.on('fs.listIos', (serial: string, rc: string, data: any) => + runTx(serial, rc, FileSystemListMessage, {dir: data.dir})) + socket.on('fs.retrieveIos', (serial: string, rc: string, data: any) => + runTx(serial, rc, FileSystemGetMessage, {file: data.file, jwt: req.internalJwt})) + + socket.on('policy.accept', () => { + UserModel.acceptPolicy(user.email) + }) + }) + .finally(() => { + hub.remove(socket.id) + socket.disconnect(true) + }) + .catch((err: any) => { + log.error('Client had an error, disconnecting due to probable loss of integrity: %s', err.stack) + }) + }) + + lifecycle.observe(() => { + try { + transport.close() + } + catch { + // No-op + } + }) + + server.listen(options.port) + log.info('Listening on port websockets %s', options.port) +}) diff --git a/lib/units/websocket/middleware/auth.js b/lib/units/websocket/middleware/auth.js deleted file mode 100644 index 5c94577200..0000000000 --- a/lib/units/websocket/middleware/auth.js +++ /dev/null @@ -1,34 +0,0 @@ -import dbapi from '../../../db/api.js' -import * as jwtutil from '../../../util/jwtutil.js' -import logger from '../../../util/logger.js' - - -export default (function(options) { - const log = logger.createLogger('websocket') - return function(socket, next) { - try { - let req = socket.request - const tokenRaw = socket.handshake.auth.token - const token = jwtutil.decode(tokenRaw, options.secret) - req.internalJwt = tokenRaw - return !token?.email ? next(new Error('Invalid user')) : dbapi.loadUser(token.email) - .then(function(user) { - if (user) { - req.user = user - return next() - } - else { - return next(new Error('Invalid user')) - } - }) - .catch((e) => { - log.error(e) - return next(new Error('Unknown error')) - }) - } - catch (e) { - log.error(e) - return next(new Error('Missing authorization token')) - } - } -}) diff --git a/lib/units/websocket/middleware/auth.ts b/lib/units/websocket/middleware/auth.ts new file mode 100644 index 0000000000..84df6f1b81 --- /dev/null +++ b/lib/units/websocket/middleware/auth.ts @@ -0,0 +1,43 @@ +import dbapi from '../../../db/api.js' +import * as jwtutil from '../../../util/jwtutil.js' +import logger from '../../../util/logger.js' +import type {Socket} from 'socket.io' + +interface AuthOptions { + secret: string +} + +export default (options: AuthOptions) => { + const log = logger.createLogger('websocket') + return async (socket: Socket, next: (err?: Error) => void) => { + try { + const req = socket.request as any + const tokenRaw = socket.handshake.auth.token + const token = jwtutil.decode(tokenRaw, options.secret) + req.internalJwt = tokenRaw + + if (!token?.email) { + return next(new Error('Invalid user')) + } + + try { + const user = await dbapi.loadUser(token.email) + if (user) { + req.user = user + return next() + } + else { + return next(new Error('Invalid user')) + } + } + catch (e: any) { + log.error('WS Auth error: %s', e?.message ?? 'Unknown error') + return next(new Error('Unknown error')) + } + } + catch (e: any) { + log.error('WS Auth error: %s', e?.message ?? 'Unknown error') + return next(new Error('Missing authorization token')) + } + } +} diff --git a/lib/units/websocket/middleware/cookie-session.js b/lib/units/websocket/middleware/cookie-session.js deleted file mode 100644 index 9deea3573f..0000000000 --- a/lib/units/websocket/middleware/cookie-session.js +++ /dev/null @@ -1,9 +0,0 @@ -import cookieSession from 'cookie-session' -export default (function(options) { - var session = cookieSession(options) - return function(socket, next) { - var req = socket.request - var res = Object.create(null) - session(req, res, next) - } -}) diff --git a/lib/units/websocket/middleware/cookieSession.ts b/lib/units/websocket/middleware/cookieSession.ts new file mode 100644 index 0000000000..dfb64e1aaf --- /dev/null +++ b/lib/units/websocket/middleware/cookieSession.ts @@ -0,0 +1,16 @@ +import cookieSession from 'cookie-session' +import type {Socket} from 'socket.io' + +interface CookieSessionOptions { + name: string + keys: string[] +} + +export default (options: CookieSessionOptions) => { + const session = cookieSession(options) + return (socket: Socket, next: (err?: Error) => void) => { + const req = socket.request + const res = Object.create(null) + session(req as any, res, next as any) + } +} diff --git a/lib/units/websocket/middleware/remote-ip.js b/lib/units/websocket/middleware/remote-ip.js deleted file mode 100644 index fd67e19b03..0000000000 --- a/lib/units/websocket/middleware/remote-ip.js +++ /dev/null @@ -1,8 +0,0 @@ -import proxyaddr from 'proxy-addr' -export default (function(options) { - return function(socket, next) { - var req = socket.request - req.ip = proxyaddr(req, options.trust) - next() - } -}) diff --git a/lib/units/websocket/middleware/remoteIp.ts b/lib/units/websocket/middleware/remoteIp.ts new file mode 100644 index 0000000000..0cf3382db2 --- /dev/null +++ b/lib/units/websocket/middleware/remoteIp.ts @@ -0,0 +1,17 @@ +import type {Socket} from 'socket.io' + +// TODO: switch dep +// @ts-ignore +import proxyaddr from 'proxy-addr' + +interface RemoteIpOptions { + trust: proxyaddr.TrustFunction +} + +export default (options: RemoteIpOptions) => { + return (socket: Socket, next: (err?: Error) => void) => { + const req = socket.request as any + req.ip = proxyaddr(req, options.trust) + next() + } +} diff --git a/lib/units/websocket/support/clientDispatcher.ts b/lib/units/websocket/support/clientDispatcher.ts new file mode 100644 index 0000000000..8ded76152d --- /dev/null +++ b/lib/units/websocket/support/clientDispatcher.ts @@ -0,0 +1,54 @@ +// +// The unit-wide broadcast fan-out for the websocket unit. +// +// A single WireRouter decodes each inbound broadcast once and calls dispatch(); +// the hub fans the decoded message out to each connection's per-type handler. +// A connection registers a plain map of `messageTypeName -> handler` once on +// connect and is removed on disconnect — no per-client router, no per-client +// transport subscriptions. +// +import logger from '../../../util/logger.js' + +const log = logger.createLogger('websocket:clientDispatcher') + +// A handler for one decoded broadcast message, scoped to one connection. Mirrors +// the WireRouter callback shape: (channel, message). +export type MessageHandler = (channel: string, message: any) => void + +// The per-connection handler map: message type name -> handler. +export type HandlerMap = Record + +export class ClientDispatcher { + private connections = new Map() + + // Register a connection's handler map. Called once per socket on connect. + add(id: string, handlers: HandlerMap): void { + this.connections.set(id, handlers) + } + + // Drop a connection on disconnect so it stops receiving broadcasts. + remove(id: string): void { + this.connections.delete(id) + } + + get size(): number { + return this.connections.size + } + + // Fan a decoded broadcast out to every connection that handles `typeName`. + // A throwing handler is isolated so it cannot starve the other connections. + dispatch(typeName: string, channel: string, message: any): void { + for (const handlers of this.connections.values()) { + const handler = handlers[typeName] + if (!handler) { + continue + } + try { + handler(channel, message) + } + catch (err: any) { + log.error('Broadcast handler for %s failed: %s', typeName, err?.message || err) + } + } + } +} diff --git a/lib/units/websocket/support/deviceOwnership.ts b/lib/units/websocket/support/deviceOwnership.ts new file mode 100644 index 0000000000..120c1db060 --- /dev/null +++ b/lib/units/websocket/support/deviceOwnership.ts @@ -0,0 +1,38 @@ +// +// Per-connection device ownership + provider resolution. +// +// Tracks, for one socket: +// - which serials the connection currently owns (has taken control of), and +// - the provider.name for a serial (a hint that can be remembered before or +// independently of ownership so the hot command path avoids a DB lookup). +// +export class DeviceOwnership { + private owned = new Set() + private providers = new Map() + + // Take control of a device: mark it owned and remember its provider. + claim(serial: string, providerName: string): void { + this.owned.add(serial) + this.providers.set(serial, providerName) + } + + // Give up control of a device. The provider hint is dropped too. + release(serial: string): void { + this.owned.delete(serial) + this.providers.delete(serial) + } + + // Cache a serial -> provider.name mapping without granting ownership, so a + // later command can resolve the provider without hitting the DB. + rememberProvider(serial: string, providerName: string): void { + this.providers.set(serial, providerName) + } + + isOwned(serial: string): boolean { + return this.owned.has(serial) + } + + providerOf(serial: string): string | undefined { + return this.providers.get(serial) + } +} diff --git a/lib/units/websocket/support/ownershipCache.ts b/lib/units/websocket/support/ownershipCache.ts new file mode 100644 index 0000000000..d94bac3962 --- /dev/null +++ b/lib/units/websocket/support/ownershipCache.ts @@ -0,0 +1,48 @@ +// +// Module-level TTL cache for "which devices does a user currently own". +// +// Rationale: on socket reconnect (page refresh) ownership must be seeded from +// the DB, but multiple sockets for the same email connect in a burst and we +// don't want N parallel queries. A short TTL (default 10 s) collapses the +// burst into one DB round-trip while staying fresh enough that a race between +// group.kick and the next reconnect is safe (worst-case: stale for Promise + +interface Entry { + devices: OwnedDevice[] + expiresAt: number +} + +export class OwnershipCache { + private readonly ttlMs: number + private readonly map = new Map() + + constructor(ttlMs = 10_000) { + this.ttlMs = ttlMs + } + + async get(email: string, loader: Loader): Promise { + const now = Date.now() + const entry = this.map.get(email) + if (entry && now < entry.expiresAt) { + return entry.devices + } + const devices = await loader() + this.map.set(email, {devices, expiresAt: now + this.ttlMs}) + return devices + } + + /** Force the next call to reload (e.g. after a confirmed group.kick). */ + invalidate(email: string): void { + this.map.delete(email) + } +} diff --git a/lib/util/devutil.js b/lib/util/devutil.js deleted file mode 100644 index 48ba01adf1..0000000000 --- a/lib/util/devutil.js +++ /dev/null @@ -1,241 +0,0 @@ -import util from 'util' -import split from 'split' -import syrup from '@devicefarmer/stf-syrup' -import adb from '../units/device/support/adb.js' -import properties from '../units/device/support/properties.js' -import logger from './logger.js' -export default syrup.serial() - .dependency(properties) - .dependency(adb) - .define(function(options, properties, adb) { - const log = logger.createLogger('util:devutil') - const devutil = Object.create(null) - - devutil.executeShellCommand = function(command) { - return adb.getDevice(options.serial).execOut(command).then(result => { - log.debug(`executing shell command ${command}, %s`, result) - }) - } - devutil.ensureUnusedLocalSocket = function(sock) { - return adb.getDevice().openLocal(sock) - .then(function(conn) { - conn.end() - throw new Error(util.format('Local socket "%s" should be unused', sock)) - }) - .catch((err) => { - if (err.message.indexOf('closed') !== -1) { - return sock - } - }) - } - devutil.waitForLocalSocket = (sock, timeout = 60 * 1000) => new Promise(async(resolve, reject) => { - const signal = AbortSignal.timeout(timeout) - let attempt = 0 - - signal.onabort = reject - - while (!signal.aborted) { - try { - const conn = await adb.getDevice(options.serial).openLocal(sock) - conn.sock = sock - resolve(conn) - return - } - catch (err) { - log.error(`[attempt: ${++attempt}] Error in waitForLocalSocket: ${err?.message}`) - // eslint-disable-next-line no-loop-func - await new Promise(r => setTimeout(r, 500 + attempt * 300)) - } - } - }) - devutil.listPidsByComm = function(comm, bin) { - let serial = options.serial - var users = { - shell: true - } - var findProcess = function(out) { - return new Promise(function(resolve) { - var header = true - var pids = [] - var showTotalPid = false - out.pipe(split()) - .on('data', function(chunk) { - if (header) { - header = false - } - else { - var cols = chunk.toString().split(/\s+/) - if (!showTotalPid && cols[0] === 'root') { - showTotalPid = true - } - // last column of output would be command name containing absolute path like '/data/local/tmp/minicap' - // or just binary name like 'minicap', it depends on device/ROM - var lastCol = cols.pop() - if ((lastCol === comm || lastCol === bin) && users[cols[0]]) { - pids.push(Number(cols[1])) - } - } - }) - .on('end', function() { - resolve({showTotalPid: showTotalPid, pids: pids}) - }) - }) - } - return adb.getDevice(serial).shell('ps 2>/dev/null') - .then(findProcess) - .then(function(res) { - // return pids if process can be found in the output of 'ps' command - // or 'ps' command has already displayed all the processes including processes launched by root user - if (res.showTotalPid || res.pids.length > 0) { - return Promise.resolve(res.pids) - } - // otherwise try to run 'ps -elf' - else { - return adb.getDevice(serial).shell('ps -lef 2>/dev/null') - .then(findProcess) - .then(function(res) { - return Promise.resolve(res.pids) - }) - } - }) - } - devutil.waitForProcsToDie = function(comm, bin) { - return devutil.listPidsByComm(comm, bin) - .then(async(pids) => { - if (pids.length) { - await new Promise(r => setTimeout(r, 100)) - return devutil.waitForProcsToDie(comm, bin) - } - }) - } - devutil.killProcsByComm = function(comm, bin, mode) { - return devutil.listPidsByComm(comm, bin, mode) - .then(function(pids) { - if (!pids.length) { - return Promise.resolve() - } - return adb.getDevice(options.serial).shell(['kill', mode || -15].concat(pids)) - .then(function(out) { - return new Promise(function(resolve) { - out.on('end', resolve) - }) - }) - .then(function() { - return devutil.waitForProcsToDie(comm, bin) - }) - .catch(function() { - return devutil.killProcsByComm(comm, bin, -9) - }) - }) - } - devutil.makeIdentity = async function() { - let serial = options.serial - let model = properties['ro.product.model'] - let brand = properties['ro.product.brand'] - let manufacturer = properties['ro.product.manufacturer'] - let operator = properties['gsm.sim.operator.alpha'] || - properties['gsm.operator.alpha'] - let version = properties['ro.build.version.release'] - let sdk = properties['ro.build.version.sdk'] - let abi = properties['ro.product.cpu.abi'] - let product = properties['ro.product.name'] - let cpuPlatform = properties['ro.board.platform'] - let openGLESVersion = properties['ro.opengles.version'] - let marketName = await devutil.getDeviceMarketName() - if (!marketName) { - console.warn('Can\'t get marketing name for device, will be used: \'default\'.') - marketName = 'default' - } - let customMarketName = properties['debug.stf.product.device'] - let macAddress = properties.mac_address - let ram = properties.ram - openGLESVersion = parseInt(openGLESVersion, 10) - if (isNaN(openGLESVersion)) { - openGLESVersion = '0.0' - } - else { - var openGLESVersionMajor = (openGLESVersion & 0xffff0000) >> 16 - var openGLESVersionMinor = (openGLESVersion & 0xffff) - openGLESVersion = openGLESVersionMajor + '.' + openGLESVersionMinor - } - // Remove brand prefix for consistency. Note that some devices (e.g. TPS650) - // do not expose the brand property. - if (brand && model.substr(0, brand.length) === brand) { - model = model.substr(brand.length) - } - // Remove manufacturer prefix for consistency - if (model.substr(0, manufacturer.length) === manufacturer) { - model = model.substr(manufacturer.length) - } - // update device name for human readable values based on env variables - var deviceUdid = process.env.DEVICE_UDID - var deviceName = process.env.STF_PROVIDER_DEVICE_NAME - console.log('Attacted device udid: ' + deviceUdid + '; name: ' + deviceName) - if (serial === deviceUdid) { - model = deviceName - } - if (customMarketName) { - marketName = customMarketName - } - // Clean up remaining model name - // model = model.replace(/[_ ]/g, '') - return { - serial: serial, - platform: 'Android', - manufacturer: manufacturer.toUpperCase(), - operator: operator || null, - model: model, - version: version, - abi: abi, - sdk: sdk, - product: product, - cpuPlatform: cpuPlatform, - openGLESVersion: openGLESVersion, - marketName: marketName, - macAddress: macAddress, - ram: ram - } - } - - devutil.getDeviceMarketName = function() { - let serial = options.serial - let manufacturer = properties['ro.product.manufacturer'] - return adb.getDevice(serial).execOut('settings get global device_name', 'utf-8').then(function(deviceName) { - if (!deviceName || deviceName === 'null\n') { - return adb.getDevice(serial).execOut('settings get secure bluetooth_name', 'utf-8').then(function(bluetoothName) { - if (!bluetoothName || bluetoothName === 'null\n') { - switch (manufacturer.toUpperCase()) { - case 'ARCHOS': - case 'GOOGLE': - return properties['ro.product.model'] - case 'HMD GLOBAL': - return properties['ro.product.nickname'] - case 'OPPO': - return adb.getDevice(serial).execOut('settings get secure oppo_device_name', 'utf-8').then(function(oppoDeviceName) { - if (!oppoDeviceName || oppoDeviceName === 'null\n') { - return properties['ro.oppo.market.name'] - } - return oppoDeviceName - }) - case 'HUAWEI': - return properties['ro.config.marketing_name'] - case 'XIAOMI': - return properties['ro.config.marketing_name'] - case 'ITEL MOBILE LIMITED': - return properties['transsion.device.name'] - default: - return properties['ro.product.device'] - } - } - return bluetoothName - }) - } - return deviceName - }).catch(function(err) { - log.error('Can\'t get marketing name for device, will be used: \'default\'.\n' + - 'Unexpected error: %s', err) - return 'default' - }) - } - return devutil - }) diff --git a/lib/util/devutil.ts b/lib/util/devutil.ts new file mode 100644 index 0000000000..09f4d83173 --- /dev/null +++ b/lib/util/devutil.ts @@ -0,0 +1,515 @@ +import util from 'util' +import split from 'split' +import syrup from '@devicefarmer/stf-syrup' +import type {Client as AdbClient} from '@u4/adbkit' +import type {Duplex, Readable} from 'node:stream' + +import adb from '../units/device/support/adb.js' +import properties from '../units/device/support/properties.js' +import logger from './logger.js' +import * as streamutil from './streamutil.js' + +export interface DisplayInfo { + id: number + width: number + height: number + xdpi: number + ydpi: number + fps: number + density: number + rotation: number + secure: boolean + size: number +} + +export type TaggedSocket = Duplex & {sock: string} + +export interface Identity { + serial: string + platform: 'Android' + manufacturer: string + operator: string | null + model: string + version: string + abi: string + sdk: string + product: string + cpuPlatform: string + openGLESVersion: string + marketName: string + macAddress: string + ram: string | number +} + +interface DevUtilOptions { + serial: string + deviceCode?: string + [key: string]: unknown +} + +interface PsResult { + showTotalPid: boolean + pids: number[] +} + +export interface DevUtil { + executeShellCommand: (command: string) => Promise + ensureUnusedLocalSocket: (sock: string) => Promise + waitForLocalSocket: (sock: string, timeout?: number) => Promise + listPidsByComm: (comm: string, bin: string) => Promise + waitForProcsToDie: (comm: string, bin: string) => Promise + killProcsByComm: (comm: string, bin: string, mode?: number) => Promise + makeIdentity: () => Promise + getDeviceMarketName: () => Promise + getDisplayInfo: (id?: number) => Promise +} + +const Manufacturers = { + HUAWEI: ['HUAWEI', 'HONOR'], + XIAOMI: ['XIAOMI', 'REDMI', 'POCO'], + SAMSUNG: ['SAMSUNG'], +} + +const isAny = (haystack: string, needles: readonly string[]): boolean => + needles.some((n) => haystack.toUpperCase().includes(n)) + +async function shellToString(adb: AdbClient, serial: string, command: string): Promise { + try { + const stream = await adb.getDevice(serial).shell(command) + const buf: Buffer = await streamutil.readAll(stream) + return buf.toString('utf8') + } + catch { + return '' + } +} + +function parseWmSize(out: string): {physical?: [number, number]; override?: [number, number]} { + const result: {physical?: [number, number]; override?: [number, number]} = {} + const phys = /Physical size:\s*(\d+)x(\d+)/i.exec(out) + const over = /Override size:\s*(\d+)x(\d+)/i.exec(out) + if (phys) result.physical = [parseInt(phys[1], 10), parseInt(phys[2], 10)] + if (over) result.override = [parseInt(over[1], 10), parseInt(over[2], 10)] + return result +} + +function parseWmDensity(out: string): {physical?: number; override?: number} { + const result: {physical?: number; override?: number} = {} + const phys = /Physical density:\s*(\d+)/i.exec(out) + const over = /Override density:\s*(\d+)/i.exec(out) + if (phys) result.physical = parseInt(phys[1], 10) + if (over) result.override = parseInt(over[1], 10) + return result +} + +function parseDumpsysDisplay(out: string): { + width?: number + height?: number + density?: number + rotation?: number + fps?: number + secure?: boolean + xdpi?: number + ydpi?: number +} { + const r: { + width?: number + height?: number + density?: number + rotation?: number + fps?: number + secure?: boolean + xdpi?: number + ydpi?: number + } = {} + + // mBaseDisplayInfo / DisplayInfo: "real 1080 x 2400", or "real 1080x2400", or + // "1080 x 2400, ... density ..." in DisplayDeviceInfo. + const real = /real\s+(\d+)\s*x\s*(\d+)/i.exec(out) + if (real) { + r.width = parseInt(real[1], 10) + r.height = parseInt(real[2], 10) + } else { + const m = /DisplayDeviceInfo\{[^}]*?(\d+)\s*x\s*(\d+)/i.exec(out) + if (m) { + r.width = parseInt(m[1], 10) + r.height = parseInt(m[2], 10) + } + } + + const density = /density\s+(\d+(?:\.\d+)?)/i.exec(out) ?? /density:\s*(\d+(?:\.\d+)?)/i.exec(out) + if (density) r.density = parseFloat(density[1]) + + const rotation = /rotation\s+(\d+)/i.exec(out) + if (rotation) r.rotation = parseInt(rotation[1], 10) * 90 // dumpsys reports 0..3 + + const fps = /(\d+(?:\.\d+)?)\s*fps/i.exec(out) + if (fps) r.fps = parseFloat(fps[1]) + + if (/FLAG_SECURE/.test(out)) r.secure = true + + const xdpi = /xDpi[=:]\s*(\d+(?:\.\d+)?)/i.exec(out) + const ydpi = /yDpi[=:]\s*(\d+(?:\.\d+)?)/i.exec(out) + if (xdpi) r.xdpi = parseFloat(xdpi[1]) + if (ydpi) r.ydpi = parseFloat(ydpi[1]) + + return r +} + +function buildDisplayInfo( + id: number, + width: number, + height: number, + overrides: Partial = {} +): DisplayInfo { + const xdpi = overrides.xdpi ?? overrides.density ?? 240 + const ydpi = overrides.ydpi ?? overrides.density ?? 240 + return { + id, + width, + height, + xdpi, + ydpi, + fps: overrides.fps ?? 60, + density: overrides.density ?? 1, + rotation: overrides.rotation ?? 0, + secure: overrides.secure ?? false, + size: + xdpi > 0 && ydpi > 0 + ? Math.sqrt(Math.pow(width / xdpi, 2) + Math.pow(height / ydpi, 2)) + : 0, + } +} + +export default syrup.serial() + .dependency(properties) + .dependency(adb) + .define(function(options: DevUtilOptions, properties: Record, adb: AdbClient) { + const log = logger.createLogger('util:devutil') + const devutil = Object.create(null) as DevUtil + + devutil.executeShellCommand = function(command: string): Promise { + return adb.getDevice(options.serial).execOut(command).then((result) => { + log.debug(`executing shell command ${command}, %s`, result) + }) + } + + devutil.ensureUnusedLocalSocket = function(sock: string): Promise { + return adb.getDevice(options.serial).openLocal(sock) + .then(function(conn): string | undefined { + conn.end() + throw new Error(util.format('Local socket "%s" should be unused', sock)) + }) + .catch((err: Error): string | undefined => { + if (err.message.indexOf('closed') !== -1) { + return sock + } + return undefined + }) + } + + devutil.waitForLocalSocket = (sock: string, timeout = 60 * 1000): Promise => + new Promise(async(resolve, reject) => { + const signal = AbortSignal.timeout(timeout) + let attempt = 0 + + signal.onabort = (): void => reject(signal.reason) + + while (!signal.aborted) { + try { + const conn = await adb.getDevice(options.serial).openLocal(sock) as TaggedSocket + conn.sock = sock + resolve(conn) + return + } catch (err: any) { + log.error(`[attempt: ${++attempt}] Error in waitForLocalSocket: ${err?.message}`) + await new Promise((r) => setTimeout(r, 500 + attempt * 300)) + } + } + }) + + devutil.listPidsByComm = function(comm: string, bin: string): Promise { + const serial = options.serial + const users: Record = {shell: true} + const findProcess = function(out: Readable): Promise { + return new Promise(function(resolve) { + let header = true + const pids: number[] = [] + let showTotalPid = false + out.pipe(split()) + .on('data', function(chunk: Buffer | string) { + if (header) { + header = false + } else { + const cols = chunk.toString().split(/\s+/) + if (!showTotalPid && cols[0] === 'root') { + showTotalPid = true + } + const lastCol = cols.pop() + if ((lastCol === comm || lastCol === bin) && users[cols[0]]) { + pids.push(Number(cols[1])) + } + } + }) + .on('end', function() { + resolve({showTotalPid, pids}) + }) + }) + } + return adb.getDevice(serial).shell('ps 2>/dev/null') + .then((stream) => findProcess(stream)) + .then(function(res): number[] | Promise { + if (res.showTotalPid || res.pids.length > 0) { + return res.pids + } + return adb.getDevice(serial).shell('ps -lef 2>/dev/null') + .then((stream) => findProcess(stream)) + .then((res2) => res2.pids) + }) + } + + devutil.waitForProcsToDie = function(comm: string, bin: string): Promise { + return devutil.listPidsByComm(comm, bin) + .then(async(pids): Promise => { + if (pids.length) { + await new Promise((r) => setTimeout(r, 100)) + return devutil.waitForProcsToDie(comm, bin) + } + }) + } + + devutil.killProcsByComm = function(comm: string, bin: string, mode?: number): Promise { + return devutil.listPidsByComm(comm, bin) + .then(function(pids): Promise { + if (!pids.length) { + return Promise.resolve() + } + return adb.getDevice(options.serial).shell(['kill', String(mode ?? -15)].concat(pids.map(String))) + .then((out) => new Promise((resolve) => { + out.on('end', () => resolve()) + })) + .then(() => devutil.waitForProcsToDie(comm, bin)) + .catch(() => devutil.killProcsByComm(comm, bin, -9)) + }) + } + + devutil.makeIdentity = async function(): Promise { + const serial = options.serial + let model = properties['ro.product.model'] + const brand = properties['ro.product.brand'] + const manufacturer = properties['ro.product.manufacturer'] + const operator = properties['gsm.sim.operator.alpha'] || + properties['gsm.operator.alpha'] + const version = properties['ro.build.version.release'] + const sdk = properties['ro.build.version.sdk'] + const abi = properties['ro.product.cpu.abi'] + const product = properties['ro.product.name'] + const cpuPlatform = properties['ro.board.platform'] + let openGLESVersionRaw: string | number = properties['ro.opengles.version'] + let marketName = await devutil.getDeviceMarketName() + if (!marketName) { + console.warn('Can\'t get marketing name for device, will be used: \'default\'.') + marketName = 'default' + } + const customMarketName = properties['debug.stf.product.device'] + const macAddress = (properties as Record).mac_address + const ram = (properties as Record).ram + let openGLESVersion: string + const parsedOgl = parseInt(String(openGLESVersionRaw), 10) + if (isNaN(parsedOgl)) { + openGLESVersion = '0.0' + } else { + const openGLESVersionMajor = (parsedOgl & 0xffff0000) >> 16 + const openGLESVersionMinor = parsedOgl & 0xffff + openGLESVersion = openGLESVersionMajor + '.' + openGLESVersionMinor + } + // Remove brand prefix for consistency. Note that some devices (e.g. TPS650) + // do not expose the brand property. + if (brand && model.substr(0, brand.length) === brand) { + model = model.substr(brand.length) + } + // Remove manufacturer prefix for consistency + if (model.substr(0, manufacturer.length) === manufacturer) { + model = model.substr(manufacturer.length) + } + // update device name for human readable values based on env variables + const deviceUdid = process.env.DEVICE_UDID + const deviceName = process.env.STF_PROVIDER_DEVICE_NAME + console.log('Attacted device udid: ' + deviceUdid + '; name: ' + deviceName) + if (serial === deviceUdid && deviceName) { + model = deviceName + } + if (customMarketName) { + marketName = customMarketName + } + return { + serial, + platform: 'Android', + manufacturer: manufacturer.toUpperCase(), + operator: operator || null, + model, + version, + abi, + sdk, + product, + cpuPlatform, + openGLESVersion, + marketName, + macAddress, + ram, + } + } + + devutil.getDeviceMarketName = function(): Promise { + const serial = options.serial + const manufacturer = (properties['ro.product.manufacturer'] || '').toUpperCase() + return adb.getDevice(serial).execOut('settings get global device_name', 'utf-8').then(function(deviceName) { + if (!deviceName || deviceName === 'null\n') { + return adb.getDevice(serial).execOut('settings get secure bluetooth_name', 'utf-8').then(function(bluetoothName) { + if (!bluetoothName || bluetoothName === 'null\n') { + switch (manufacturer) { + case 'ARCHOS': + case 'GOOGLE': + return properties['ro.product.model'] + case 'HMD GLOBAL': + return properties['ro.product.nickname'] + case 'OPPO': + return adb.getDevice(serial).execOut('settings get secure oppo_device_name', 'utf-8').then(function(oppoDeviceName) { + if (!oppoDeviceName || oppoDeviceName === 'null\n') { + return properties['ro.oppo.market.name'] + } + return String(oppoDeviceName) + }) + case 'HUAWEI': + return properties['ro.config.marketing_name'] + case 'XIAOMI': + return properties['ro.config.marketing_name'] + case 'ITEL MOBILE LIMITED': + return properties['transsion.device.name'] + default: + return properties['ro.product.device'] + } + } + return String(bluetoothName) + }) + } + return String(deviceName) + }).catch(function(err: Error): string { + log.error(util.format( + 'Can\'t get marketing name for device, will be used: \'default\'.\nUnexpected error: %s', + err + )) + return 'default' + }) + } + + /** + * Vendor-resilient display detection strategy: + * 1. `wm size` -- Physical / Override resolution + * 2. `wm density` -- Physical / Override density + * 3. `dumpsys display` -- real WxH, density, rotation, fps, secure + * 4. Vendor switch -- pick Physical vs Override per OEM quirks + * 5. Result -- buildDisplayInfo() composes the final object + * + * Vendor notes: + * - HUAWEI / HONOR: EMUI's display-scaling overrides are unreliable; prefer + * `dumpsys display` real dimensions, fall back to Physical size from wm. + * - XIAOMI / REDMI / POCO: MIUI's "display size" setting silently rewrites + * the Override size. Always use Physical size to stay aligned with what + * minicap actually streams. + * - SAMSUNG: behaves like default; DEX-mode devices may expose additional + * internal displays but for id=0 the Override behavior is correct. + * - Default: prefer Override (it's what the user has actively set) and fall + * back to Physical. + * + * Returns zero dimensions only if every source failed. + */ + devutil.getDisplayInfo = async function(id: number = 0): Promise { + const serial = options.serial + const manufacturer = (properties['ro.product.manufacturer'] || '').toUpperCase() + + // Run shell commands in parallel -- they don't depend on each other. + const [wmSizeOut, wmDensityOut, dumpsysDisplayOut] = await Promise.all([ + shellToString(adb, serial, 'wm size'), + shellToString(adb, serial, 'wm density'), + shellToString(adb, serial, 'dumpsys display'), + ]) + + const wmSize = parseWmSize(wmSizeOut) + const wmDensity = parseWmDensity(wmDensityOut) + const dumpsys = parseDumpsysDisplay(dumpsysDisplayOut) + + // Pick width/height based on vendor. + let width = 0 + let height = 0 + + if (isAny(manufacturer, Manufacturers.HUAWEI)) { + // Prefer dumpsys "real WxH"; fall back to Physical size. + if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } else if (wmSize.physical) { + [width, height] = wmSize.physical + } + log.debug(util.format('Display probe (HUAWEI/HONOR): chose %dx%d', width, height)) + } else if (isAny(manufacturer, Manufacturers.XIAOMI)) { + // MIUI's Override size lies -- always use Physical, fall back to dumpsys. + if (wmSize.physical) { + [width, height] = wmSize.physical + } else if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } + log.debug(util.format('Display probe (XIAOMI/REDMI/POCO): chose %dx%d (Physical only)', width, height)) + } else if (isAny(manufacturer, Manufacturers.SAMSUNG)) { + // Samsung honors Override when present. + if (wmSize.override) { + [width, height] = wmSize.override + } else if (wmSize.physical) { + [width, height] = wmSize.physical + } else if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } + log.debug(util.format('Display probe (SAMSUNG): chose %dx%d', width, height)) + } else { + // Generic: prefer Override if user explicitly set one, else Physical, else dumpsys. + if (wmSize.override) { + [width, height] = wmSize.override + } else if (wmSize.physical) { + [width, height] = wmSize.physical + } else if (dumpsys.width && dumpsys.height) { + width = dumpsys.width + height = dumpsys.height + } + log.debug(util.format('Display probe (default): chose %dx%d', width, height)) + } + + // Density: prefer override (matches what apps actually see), then physical, then dumpsys, then ro.sf.lcd_density. + let density: number | undefined + if (isAny(manufacturer, Manufacturers.XIAOMI)) { + density = wmDensity.physical ?? wmDensity.override ?? dumpsys.density + } else { + density = wmDensity.override ?? wmDensity.physical ?? dumpsys.density + } + if (!density) { + const lcd = parseInt(properties['ro.sf.lcd_density'] || '', 10) + if (!isNaN(lcd)) density = lcd + } + + // Convert density (dpi) to a multiplier when buildDisplayInfo expects "scale"-ish. + // Historically `density` in DisplayInfo is the multiplier (e.g. 2.0 for xhdpi). + const densityMultiplier = density ? density / 160 : undefined + + return buildDisplayInfo(id, width, height, { + density: densityMultiplier, + xdpi: density, + ydpi: density, + rotation: dumpsys.rotation, + fps: dumpsys.fps, + secure: dumpsys.secure, + }) + } + + return devutil + }) diff --git a/lib/util/lifecycle.ts b/lib/util/lifecycle.ts index afc65dacf6..cfbba4bc40 100644 --- a/lib/util/lifecycle.ts +++ b/lib/util/lifecycle.ts @@ -7,6 +7,7 @@ type LifecycleObserver = () => Promise | unknown export default new (class Lifecycle { cleanups: LifecycleObserver[] = [] + cleanupsFatal: LifecycleObserver[] = [] ending = false constructor() { @@ -54,13 +55,21 @@ export default new (class Lifecycle { ) } + // @ts-ignore fatal(err?: Error | string): never { log.fatal(`Shutting down due to fatal error ${err || ''}`) this.ending = true - process.exit(1) + // process.exit(1) + Promise.all(this.cleanupsFatal.map((fn) => fn())).then(() => + process.exit(1) + ) } observe(cleanupFn: LifecycleObserver) { this.cleanups.push(cleanupFn) } + + observeFatal(cleanupFn: LifecycleObserver) { + this.cleanupsFatal.push(cleanupFn) + } })() diff --git a/lib/util/logger.ts b/lib/util/logger.ts index 1c70342bd8..0020e50ed5 100644 --- a/lib/util/logger.ts +++ b/lib/util/logger.ts @@ -51,7 +51,7 @@ export class Log extends EventEmitter { 'groups-engine': [chalk.gray, chalk.bgBlack], poorxy: [chalk.gray, chalk.bgBlack], api: [chalk.green, chalk.bgGreen], - triproxy: [chalk.magenta, chalk.bgMagenta], + proxy: [chalk.magenta, chalk.bgMagenta], processor: [chalk.red, chalk.bgRed], provider: [chalk.blue, chalk.bgBlue], device: [chalk.cyan, chalk.bgCyan], diff --git a/lib/util/srv.ts b/lib/util/srv.ts index ba2114852d..d0389e6829 100644 --- a/lib/util/srv.ts +++ b/lib/util/srv.ts @@ -70,19 +70,19 @@ export const resolve = async function(domain: string) { ]) } } -export const attempt = >( +export const attempt = , T>( records: R, - fn: (record: R[0]) => Promise | unknown -) => { - async function next(i: number) { + fn: (record: R[0]) => Promise | T +): Promise => { + async function next(i: number): Promise { if (i >= records.length) { throw new Error('No more records left to try') } try { - await fn(records[i]) + return await fn(records[i]) } catch (err) { - next(i + 1) + return next(i + 1) } } return next(0) diff --git a/lib/util/ttlset.ts b/lib/util/ttlset.ts index 0c85c36b59..34ec502726 100644 --- a/lib/util/ttlset.ts +++ b/lib/util/ttlset.ts @@ -12,8 +12,15 @@ class TTLItem { class TTLSet extends EventEmitter { private head: TTLItem | null = null private tail: TTLItem | null = null - private mapping: Record = {} + // Map preserves a stable hidden class on delete (no V8 dictionary-mode + // thrash when devices disconnect). Record degrades to dictionary + // mode on the first `delete`, making every subsequent access megamorphic. + private mapping = new Map() private timer: NodeJS.Timeout | null = null + // Track the absolute timestamp of the next scheduled expiry so we can skip + // clearTimeout+setTimeout on bumps that don't advance the deadline (the + // common case: bumping a device that is not near the head). + private nextExpiry = Infinity static SILENT = 1 @@ -23,36 +30,58 @@ class TTLSet extends EventEmitter { super() } + // Move `value` to the tail (freshest end) with a new timestamp, inserting + // it if unknown. remove() nulls both links, so the item is always re-linked + // from scratch; the list is kept ordered oldest-first (head) to newest (tail). bump(value: string, time: number, flags?: any) { - const item = - this.remove(this.mapping[value]) || - this.create(value, this.tail, flags) + const existing = this.mapping.get(value) + const item = existing + ? this.remove(existing)! + : this.create(value, flags) item.time = time || Date.now() - this.tail = item - if (item.prev) { - item.prev.next = item + + // Append at the tail: the list must stay ordered oldest-first, because + // check() only ever walks it from the head and stops at the first live + // item. + item.prev = this.tail + item.next = null + if (this.tail) { + this.tail.next = item } else { this.head = item } + this.tail = item this.scheduleCheck() } drop(value: string, flags?: any) { - this._drop(this.mapping[value], flags) + this._drop(this.mapping.get(value), flags) } stop() { clearTimeout(this.timer!) } + // Only arm a new timer when the new head expiry is EARLIER than the one + // already scheduled. On a steady stream of heartbeats the head barely moves — + // most bump() calls skip clearTimeout+setTimeout entirely, eliminating O(N) + // timer allocations per heartbeat cycle. private scheduleCheck() { - clearTimeout(this.timer!) - if (this.head) { - const delay = Math.max(0, this.ttl - (Date.now() - this.head.time)) - this.timer = setTimeout(this.check.bind(this), delay) + if (!this.head) { + return + } + const nextDue = this.head.time + this.ttl + if (nextDue < this.nextExpiry) { + clearTimeout(this.timer!) + this.nextExpiry = nextDue + const delay = Math.max(0, nextDue - Date.now()) + this.timer = setTimeout(() => { + this.nextExpiry = Infinity + this.check() + }, delay) } } @@ -70,22 +99,30 @@ class TTLSet extends EventEmitter { this.scheduleCheck() } - private create(value: string, prev: TTLItem | null, flags: any) { - const item = new TTLItem(value, 0, prev, null) - this.mapping[value] = item + // Create a detached item; bump() links it into the list. + private create(value: string, flags: any) { + const item = new TTLItem(value, 0, null, null) + this.mapping.set(value, item) if ((flags & TTLSet.SILENT) !== TTLSet.SILENT) { this.emit('insert', value) } return item } - private _drop(item: TTLItem, flags?: any) { + private _drop(item: TTLItem | undefined | null, flags?: any) { if (item) { + // If we are removing the current head the next-due deadline + // changes; reset nextExpiry so scheduleCheck() recalculates it + // against the new head. + const wasHead = item === this.head this.remove(item) - delete this.mapping[item.value] + this.mapping.delete(item.value) if ((flags & TTLSet.SILENT) !== TTLSet.SILENT) { this.emit('drop', item.value) } + if (wasHead) { + this.nextExpiry = Infinity + } } } diff --git a/lib/util/zmqsocket.ts b/lib/util/zmqsocket.ts new file mode 100644 index 0000000000..2d0e0cee92 --- /dev/null +++ b/lib/util/zmqsocket.ts @@ -0,0 +1,210 @@ +// +// Thin ROUTER/DEALER layer over native zeromq v6. +// +// Works with raw multipart Buffer[] as required by the frame protocol +// (lib/wire/frame.ts): +// - a ROUTER prepends the sender routing id as frame 0 on receive, and takes +// the target routing id as frame 0 on send; +// - a DEALER receives body frames as-is and its identity is set once via +// ZMQ_ROUTING_ID. +// +import {Router, Dealer, Context} from 'zeromq' +import {EventEmitter} from 'events' +import logger from './logger.js' + +const log = logger.createLogger('util:zmqsocket') + +// Shared ZMQ context so we don't spin up multiple io-thread pools. +let sharedContext: Context +const getSharedContext = () => { + if (!sharedContext) { + sharedContext = new Context({ + blocky: true, + ioThreads: 4, + ipv6: true, + maxSockets: 8192, + }) + } + return sharedContext +} + +const KEEPALIVE = Number(process.env.ZMQ_KEEPALIVE_INTERVAL || 30) + +const baseSocketOptions = () => ({ + linger: 2000, + tcpKeepalive: 1, + tcpKeepaliveIdle: KEEPALIVE, + tcpKeepaliveInterval: KEEPALIVE, + tcpKeepaliveCount: 100, + context: getSharedContext(), +}) + +abstract class BaseSocket extends EventEmitter { + protected abstract socket: T + protected active = true + private looping = false + // Tail-chain serialises concurrent send() calls. zeromq v6 rejects any + // send() while a previous one is in flight ("Socket is busy writing"). + // Each call chains onto the tail; the chain GC's as each link resolves. + // Zero allocations beyond the promise itself — no array, no queue. + private sendTail: Promise = Promise.resolve() + + // Drives the async receive loop; emits 'frames' with the raw Buffer[]. + protected startReceiveLoop() { + if (this.looping) { + return + } + this.looping = true + ;(async () => { + try { + for await (const frames of this.socket as AsyncIterable) { + if (!this.active) { + break + } + this.emit('frames', frames) + } + } + catch (err: any) { + if (this.active) { + log.error('Receive loop error: %s', err?.message || err) + } + } + })() + } + + send(frames: Buffer[]): Promise { + if (!this.active) { + return Promise.resolve(this) + } + // Tail-chain: serialises concurrent sends so the native socket never + // has two in-flight calls at once (zeromq v6: "Socket is busy writing"). + // + // The tail reference is updated synchronously, so every concurrent + // caller queues before any await runs. + // + // Critical: we keep the tail always-resolving via .catch(() => {}). + // Without it, a single rejection (e.g. EHOSTUNREACH during reconnect) + // would poison sendTail permanently and silently drop all future sends. + // The caller's returned promise still sees the rejection for this send. + const next = this.sendTail.then(() => { + if (!this.active) return + return (this.socket as any).send(frames) + }) + this.sendTail = next.catch(() => {}) // swallow error, keep chain alive + return next.then(() => this) + } + + // Drain any queued sends before closing. Useful when a 'close' handler + // enqueues a final message that must be delivered before the socket shuts down. + flush(): Promise { + return this.sendTail + } + + async close() { + this.active = false + try { + this.socket.close() + } + catch { + // ignore + } + this.removeAllListeners() + } +} + +export interface RouterOptions { + // ZMQ_ROUTER_HANDOVER: accept a reconnecting peer that reuses an identity. + handover?: boolean + // ZMQ_ROUTER_MANDATORY: fail send() with EHOSTUNREACH for unknown peers + // instead of silently dropping. + mandatory?: boolean +} + +export class RouterSocket extends BaseSocket { + protected socket: Router + + constructor(options: RouterOptions = {}) { + super() + this.socket = new Router({ + ...baseSocketOptions(), + }) + // Default to the resilient/diagnostic settings; callers may override. + this.socket.handover = options.handover ?? true + this.socket.mandatory = options.mandatory ?? true + } + + async bind(address: string) { + await this.socket.bind(address) + log.info('ROUTER bound on %s', address) + this.startReceiveLoop() + return this + } +} + +export interface DealerOptions { + // ZMQ_ROUTING_ID: stable identity so a ROUTER recognises this peer + // deterministically across reconnects. + routingId?: string + // ZMQ_PROBE_ROUTER: send an empty message on connect so the ROUTER learns + // the identity before the first application message. + probeRouter?: boolean + // Whether to run the receive loop (an app-side DEALER receives; a pure + // sender may skip it). + receive?: boolean +} + +export class DealerSocket extends BaseSocket { + protected socket: Dealer + + constructor(options: DealerOptions = {}) { + super() + this.socket = new Dealer({ + ...baseSocketOptions(), + ...(options.routingId != null && {routingId: options.routingId}), + }) + if (options.probeRouter != null) { + this.socket.probeRouter = options.probeRouter + } + if (options.receive ?? true) { + this.startReceiveLoop() + } + } + + connect(address: string) { + this.socket.connect(address) + log.verbose('DEALER connected to %s', address) + return this + } + + // Emit 'reconnect' every time the underlying socket (re)establishes a + // connection AFTER the first one. ZMQ reconnects transparently, but the peer + // ROUTER is a fresh process with an empty routing table, so any state we + // previously pushed to it (ANNOUNCE, HELLO) has to be pushed again. Opt-in, + // because it starts a second async loop over the socket's event observer. + watchReconnect() { + let seenFirst = false + ;(async () => { + try { + for await (const event of this.socket.events) { + if (!this.active) { + break + } + if (event.type !== 'connect') { + continue + } + if (!seenFirst) { + seenFirst = true + continue + } + this.emit('reconnect') + } + } + catch (err: any) { + if (this.active) { + log.error('Event loop error: %s', err?.message || err) + } + } + })() + return this + } +} diff --git a/lib/util/zmqutil.js b/lib/util/zmqutil.js deleted file mode 100644 index 3a4d67b438..0000000000 --- a/lib/util/zmqutil.js +++ /dev/null @@ -1,196 +0,0 @@ -// -// Copyright 2025 contains code contributed by V Kontakte LLC - Licensed under the Apache license 2.0 -// -// This wrapper is designed to make 0MQ v6 backwards compatible with v5 - -import * as zmq from 'zeromq' -import logger from './logger.js' -import {EventEmitter} from 'events' -const log = logger.createLogger('util:zmqutil') - -const socketTypeMap = { - pub: zmq.Publisher, - sub: zmq.Subscriber, - push: zmq.Push, - pull: zmq.Pull, - dealer: zmq.Dealer, - router: zmq.Router, - pair: zmq.Pair, - req: zmq.Request, - reply: zmq.Reply -} - -// Shared ZMQ context to avoid creating multiple contexts with thread pools -// Each context creates ioThreads (4 by default), so sharing saves resources -/** @type {zmq.Context | null} */ -let sharedContext = null -const getSharedContext = () => { - if (!sharedContext) { - sharedContext = new zmq.Context({ - blocky: true, - ioThreads: 4, - ipv6: true, - maxSockets: 8192, - }) - } - return sharedContext -} - -export class SocketWrapper extends EventEmitter { - #sendQueue = Promise.resolve() - - /** @type {AsyncIterator | null} */ - #iterator = null - - /** - * @param {string} type - * @param {number} keepAliveInterval - */ - constructor(type, keepAliveInterval = 30) { - super() - - if (!(type in socketTypeMap)) { - throw new Error(`Unsupported socket type: ${type}`) - } - - this.type = type - this.isActive = true - this.endpoints = new Set() - - // @ts-ignore - const SocketClass = socketTypeMap[type] - this.socket = new SocketClass({ - tcpKeepalive: 1, - tcpKeepaliveIdle: keepAliveInterval, - tcpKeepaliveInterval: keepAliveInterval, - tcpKeepaliveCount: 100 - }, getSharedContext()) - } - - bindSync = (address) => this.socket.bindSync(address) - - connect(endpoint) { - this.socket.connect(endpoint) - this.endpoints.add(endpoint) - log.verbose(`Socket connected to: ${endpoint}`) - - return this - } - - subscribe(topic) { - if (this.type === 'sub') { - this.socket.subscribe( - typeof topic === 'string' ? Buffer.from(topic) : topic - ) - } - - return this - } - - unsubscribe(topic) { - if (this.type === 'sub') { - this.socket.unsubscribe( - typeof topic === 'string' ? Buffer.from(topic) : topic - ) - } - return this - } - - async sendAsync(args) { - try { - await this.socket.send( - (Array.isArray(args) ? args : [args]) - .map(arg => Buffer.isBuffer(arg) || ArrayBuffer.isView(arg) ? arg : Buffer.from(String(arg))) - ) - } - catch (/** @type {any} */ err) { - log.error('Error on send: %s', err?.message || err?.toString() || JSON.stringify(err)) - throw err // Re-throw to properly handle in the promise chain - } - } - - /** - * @param {any} args - */ - send(args) { - this.#sendQueue = this.#sendQueue.then(() => this.sendAsync(args)) - return this - } - - async close() { - this.isActive = false - - // Close async iterator if it exists - if (this.#iterator && typeof this.#iterator.return === 'function') { - try { - await this.#iterator.return() - } - catch { - // Ignore errors during cleanup - } - this.#iterator = null - } - - // Wait for send queue to drain before closing socket - try { - await this.#sendQueue.catch(() => {}) - } - catch { - // Ignore errors during cleanup - } - - this.socket.close() - this.removeAllListeners() - - return this - } - - /** - * - * @returns {Promise} - */ - async startReceiveLoop() { - const isValidType = - this.type === 'sub' || - this.type === 'pull' || - this.type === 'dealer' || - this.type === 'router' || - this.type === 'reply' - - if (!this.isActive || !isValidType) { - return - } - - try { - this.#iterator = this.socket[Symbol.asyncIterator]() - let result - - while (this.isActive && this.#iterator && !(result = await this.#iterator.next()).done) { - const message = result.value - - if (Array.isArray(message) && !!message[0]?.toString) { - super.emit( - 'message' - , message[0].toString() - , ...message.slice(1) - ) - } - } - } - catch (/** @type {any} */ err) { - log.error('Error in message receive loop: %s, %s', err?.message || err?.toString() || err, err.stack) - return this.startReceiveLoop() - } - } -} - -export const socket = (type) => { - if (!(type in socketTypeMap)) { - throw new Error(`Unsupported socket type: ${type}`) - } - - const wrappedSocket = new SocketWrapper(type, Number(process.env.ZMQ_KEEPALIVE_INTERVAL || 30)) - wrappedSocket.startReceiveLoop() - - return wrappedSocket -} diff --git a/lib/wire/app-transport.ts b/lib/wire/app-transport.ts new file mode 100644 index 0000000000..b308b87347 --- /dev/null +++ b/lib/wire/app-transport.ts @@ -0,0 +1,149 @@ +// +// The app-side transport for the ROUTER/DEALER protocol. +// +// Every app-side unit (api, websocket, log, groups-engine) connects to the +// proxy over a SINGLE DEALER and does two things: +// +// - sends device-directed commands as [D, providerName, serial, envelope]; +// the proxy pushes this DEALER's routingId onto the reply-path and forwards +// to the processor owning that provider. A reply travels back and the proxy +// pops us off the reply-path, so we receive it as [R, envelope]; +// - optionally registers as a broadcast receiver by sending a single [S] +// frame, after which the proxy fans every broadcast out to us as +// [B, selector, envelope]. +// +// Two layers: +// - AppWire: pure framing/classification logic (no ZMQ) — testable in +// isolation. +// - AppTransport: ZMQ glue over a DealerSocket, exposing events: +// 'message' (channel, body) — a reply (R), matched by TransactionManager; +// 'broadcast' (body) — a broadcast-to-app event (B), fed into WireRouter. +// +import {EventEmitter} from 'events' +import logger from '../util/logger.js' +import type {DealerSocket} from '../util/zmqsocket.js' +import {KIND, encodeDeviceFrame, encodeBroadcast, classifyFrames, BROADCAST_ALL, Frames} from './frame.js' +import {Envelope} from './wire.js' + +const log = logger.createLogger('wire:app-transport') + +export interface InboundReply { + kind: typeof KIND.REPLY | typeof KIND.BROADCAST + // The correlationId (Envelope.channel) — only meaningful for a reply, used + // to match a pending transaction. Empty for a broadcast. + channel: string + // The Envelope body frame (the last frame), decoded by the consumer's + // WireRouter / TransactionManager. + body: Buffer +} + +export class AppWire { + // Encode a device-directed command. The reply-path starts empty; the proxy + // pushes this DEALER's routingId as it forwards up. + encodeCommand(providerName: string, serial: string, envelope: Buffer): Frames { + return encodeDeviceFrame(providerName, serial, envelope) + } + + // Encode the one-shot broadcast-receiver registration frame [S]. + encodeSubscribe(): Frames { + return [Buffer.from(KIND.SUBSCRIBE_BROADCAST)] + } + + // Encode an app-originated broadcast-to-app event as [B, selector, envelope]. + // The proxy fans it out to every registered broadcast receiver. The default + // selector (BROADCAST_ALL) targets all of them; receivers filter client-side. + encodeBroadcast(envelope: Buffer, selector: string = BROADCAST_ALL): Frames { + return encodeBroadcast(envelope, selector) + } + + // Classify one inbound multipart. The DEALER strips the identity frame and + // the proxy pops the routingId off the reply-path before delivery, so: + // [R, envelope] -> reply for a pending transaction; + // [B, selector, envelope] -> broadcast-to-app event. + // Empty probe/keepalive frames and unexpected kinds return null. + classifyInbound(frames: Frames): InboundReply | null { + if (!frames.length || (frames.length === 1 && frames[0].length === 0)) { + return null + } + const {kind} = classifyFrames(frames, {routerPrepended: false}) + const body = frames[frames.length - 1] + + if (kind === KIND.REPLY) { + const channel = Envelope.fromBinary(body).channel ?? '' + return {kind: KIND.REPLY, channel, body} + } + if (kind === KIND.BROADCAST) { + return {kind: KIND.BROADCAST, channel: '', body} + } + return null + } +} + +// The app-facing transport over a DEALER connected to the proxy. +// +// Events: +// 'message' (channel: string, body: Buffer) — reply (R); channel is +// Envelope.channel (correlationId) for TransactionManager matching. +// 'broadcast'(body: Buffer) — broadcast-to-app event (B). +export class AppTransport extends EventEmitter { + private wire = new AppWire() + private subscribed = false + + constructor(private dealer: DealerSocket) { + super() + this.dealer.on('frames', (frames: Buffer[]) => { + try { + this.onFrames(frames) + } + catch (err: any) { + log.error('Inbound framing error: %s', err?.message || err) + } + }) + } + + private onFrames(frames: Buffer[]) { + const inbound = this.wire.classifyInbound(frames) + if (!inbound) { + return + } + if (inbound.kind === KIND.REPLY) { + this.emit('message', inbound.channel, inbound.body) + } + else { + this.emit('broadcast', inbound.body) + } + } + + // Register once as a broadcast receiver. Idempotent. + registerBroadcast() { + if (this.subscribed) { + return + } + this.subscribed = true + this.dealer.send(this.wire.encodeSubscribe()).catch((err: any) => + log.warn('Broadcast registration failed: %s', err?.message)) + } + + // Send a device-directed command. `envelope` is a packed Envelope buffer + // (from wireutil.pack/tr); providerName+serial address the device. + sendCommand(providerName: string, serial: string, envelope: Uint8Array) { + const body = Buffer.isBuffer(envelope) ? envelope : Buffer.from(envelope) + this.dealer.send(this.wire.encodeCommand(providerName, serial, body)) + .catch((err: any) => log.warn('Command to %s/%s undeliverable: %s', + providerName, serial, err?.message)) + } + + // Publish a broadcast-to-app event. The proxy fans it out to every registered + // broadcast receiver; receivers filter client-side. `selector` defaults to + // all receivers. + sendBroadcast(envelope: Uint8Array, selector?: string) { + const body = Buffer.isBuffer(envelope) ? envelope : Buffer.from(envelope) + this.dealer.send(this.wire.encodeBroadcast(body, selector)) + .catch((err: any) => log.warn('Broadcast undeliverable: %s', err?.message)) + } + + close() { + this.removeAllListeners() + return this.dealer.close() + } +} diff --git a/lib/wire/device-transport.ts b/lib/wire/device-transport.ts new file mode 100644 index 0000000000..40d5253814 --- /dev/null +++ b/lib/wire/device-transport.ts @@ -0,0 +1,212 @@ +// +// The device-side transport for the ROUTER/DEALER protocol. +// +// Two layers: +// - DeviceWire: pure framing logic (no ZMQ, no timers) — testable in +// isolation. Turns a plugin's `send(channel, envelope)` into wire frames +// and remembers each inbound command's reply-path. +// - DeviceTransport: ZMQ glue over a DealerSocket, exposing the interface +// device plugins use (`send([channel, envelope])`, +// `on('message', (channel, envelope))`). +// +// A device only ever speaks to its processor. The outbound kind depends on +// the channel argument to send(): +// +// known correlationId (command's Envelope.channel) -> [R, ...replyPath, envelope] +// stale/unknown correlationId (txn_ prefix) -> dropped (command timed out) +// any other channel (global, group id, status) -> [E, envelope] (device event) +// +// Kind E carries every device-originated message that is NOT a reply: intros, +// heartbeats, status updates and group broadcasts. The processor decides by +// message type whether to consume it (dbapi) or forward it to the app side. +// +// A device receives commands as [D, providerName, serial, ...replyPath, +// envelope]. DeviceWire records the reply-path keyed by the command's +// correlationId and uses it when the plugin replies. An unknown correlationId +// is dropped. +// +import {EventEmitter} from 'events' +import logger from '../util/logger.js' +import type {DealerSocket} from '../util/zmqsocket.js' +import {KIND, CORRELATION_PREFIX, encodeEvent, encodeReply, classifyFrames} from './frame.js' +import {Envelope} from './wire.js' + +// Envelope.channel is proto field 3, wire type 2 (LEN): tag = (3 << 3) | 2 = 0x1a. +// Appending this field to an existing Envelope binary injects the channel without a +// full decode/encode round-trip. Proto optional-string last-occurrence-wins semantics +// make appending safe when a channel is already present. +// All correlationIds (txn_) are well under 128 bytes — single-byte varint length. +const CHANNEL_TAG = 0x1a + +function injectChannelField(envelope: Buffer, channel: string): Buffer { + const bytes = Buffer.from(channel, 'utf8') + const header = Buffer.allocUnsafe(2) + header[0] = CHANNEL_TAG + header[1] = bytes.length + return Buffer.concat([envelope, header, bytes]) +} + +const log = logger.createLogger('wire:device-transport') + +// How often to sweep expired reply-paths out of the DeviceWire map. +const SWEEP_INTERVAL = 60 * 1000 + +export interface DeviceWireOptions { + // How long (ms) to remember a command's reply-path before sweeping it. + ttl?: number + // Injectable clock for testing. + clock?: () => number +} + +export interface InboundCommand { + // The correlationId the command carried (Envelope.channel), handed to the + // plugin as the "channel" it used to receive on. + channel: string + // The Envelope body frame, to be decoded by the WireRouter as before. + envelope: Buffer +} + +interface PendingReplyPath { + // The reply-path frames (identities) to send the reply back along. + replyPath: Buffer[] + // The correlationId (Envelope.channel of the inbound command) — cached here + // so reply() never needs to decode the reply envelope again. + correlationId: string + // When this entry was recorded (for TTL sweeping). + at: number +} + +const DEFAULT_TTL = 5 * 60 * 1000 // 5 minutes: longer than any transaction. + +export class DeviceWire { + private ttl: number + private clock: () => number + // correlationId -> the reply-path of the command that carried it. + private replyPaths = new Map() + + constructor(options: DeviceWireOptions = {}) { + this.ttl = options.ttl ?? DEFAULT_TTL + this.clock = options.clock ?? Date.now + } + + // Record an inbound command's reply-path and return the (channel, envelope) + // for the plugin layer. Framing: [D, providerName, serial, ...replyPath, envelope]. + // The reply-path is everything between the serial frame and the body. + receiveCommand(frames: Buffer[]): InboundCommand { + const envelope = frames[frames.length - 1] + const replyPath = frames.slice(3, frames.length - 1) + const channel = Envelope.fromBinary(envelope).channel ?? '' + if (channel) { + this.replyPaths.set(channel, {replyPath, correlationId: channel, at: this.clock()}) + } + return {channel, envelope} + } + + // Translate a plugin's send([channel, envelope]) into outbound frames. + // Returns null when the send should be dropped (unknown correlationId). + send(channel: string, envelope: Buffer): Buffer[] | null { + const pending = this.replyPaths.get(channel) + if (pending) { + // Do NOT delete the reply-path here: a single command may produce + // several replies (streaming progress chunks then a final okay), all + // on the same correlationId. The path is kept until the TTL sweep. + return this.reply(pending, envelope) + } + + // An unknown correlationId: the command timed out and its reply-path + // was swept. Drop rather than mis-deliver as an event. + if (channel.startsWith(CORRELATION_PREFIX)) { + return null + } + + // Anything else (global, group id, status update) is a device event. + return encodeEvent(envelope) + } + + // Drop reply-paths older than the TTL so the map cannot grow without bound + // (a command whose reply never came would otherwise leak). + sweep() { + const cutoff = this.clock() - this.ttl + for (const [channel, pending] of this.replyPaths) { + if (pending.at < cutoff) { + this.replyPaths.delete(channel) + } + } + } + + private reply(pending: PendingReplyPath, envelope: Buffer): Buffer[] { + // The reply envelope (from wireutil.reply(...)) carries no channel; the + // requester's TransactionManager matches replies by Envelope.channel, so we + // inject the correlationId via binary field append (proto field 3) instead + // of a full decode/encode round-trip. + const withChannel = injectChannelField(envelope, pending.correlationId) + return encodeReply(pending.replyPath, withChannel) + } +} + +// The plugin-facing device transport over a DEALER. Mirrors the shape plugins +// already use (`send`, `on('message')`) so no plugin needs to change. +export class DeviceTransport extends EventEmitter { + private wire: DeviceWire + private sweepTimer?: NodeJS.Timeout + private closed = false + + constructor(private dealer: DealerSocket) { + super() + this.wire = new DeviceWire() + + this.dealer.on('frames', (frames: Buffer[]) => { + try { + this.onFrames(frames) + } + catch (err: any) { + log.error('Inbound framing error: %s', err?.message || err) + } + }) + + this.sweepTimer = setInterval(() => this.wire.sweep(), SWEEP_INTERVAL) + this.sweepTimer.unref?.() + } + + private onFrames(frames: Buffer[]) { + // probeRouter/empty keepalive frames — ignore. + if (!frames.length || (frames.length === 1 && frames[0].length === 0)) { + return + } + // The device DEALER has already stripped any identity frame. + const {kind} = classifyFrames(frames, {routerPrepended: false}) + if (kind === KIND.DEVICE) { + const {channel, envelope} = this.wire.receiveCommand(frames) + // Re-emit in the shape the WireRouter consumes. + this.emit('message', channel, envelope) + } + // Any other kind targeted at a device is unexpected; drop silently. + } + + // Plugins call send([channel, envelope]); accept that array shape loosely + // (callsites are untyped legacy JS). + send(args: [string, Uint8Array] | any[]) { + if (this.closed) return + const channel = String(args[0]) + const payload = args[1] as Uint8Array + const envelope = Buffer.isBuffer(payload) ? payload : Buffer.from(payload) + const out = this.wire.send(channel, envelope) + if (!out) { + return // dropped: unknown/stale correlationId + } + this.dealer.send(out).catch((err: any) => + log.warn('Undeliverable to processor: %s', err?.message)) + } + + async close() { + if (this.closed) return + if (this.sweepTimer) { + clearInterval(this.sweepTimer) + } + + this.closed = true + this.removeAllListeners() + await this.dealer.flush() + return this.dealer.close() + } +} diff --git a/lib/wire/frame.ts b/lib/wire/frame.ts new file mode 100644 index 0000000000..04f730350b --- /dev/null +++ b/lib/wire/frame.ts @@ -0,0 +1,212 @@ +// +// Frame protocol for the ROUTER/DEALER transport. +// +// A message is a multipart ZMQ frame array. On transit, only the header frames +// are read; the Envelope body (last frame) is never decoded. +// +// Routing kind (frame 0): +// "D" device-directed: ["D", providerName, serial, ...replyPath, envelope] +// "B" broadcast-to-app: ["B", selector, envelope] +// "R" reply: ["R", ...replyPath, envelope] +// +// reply-path is a LIFO stack: intermediate nodes push their incoming routingId +// onto the tail when forwarding UP, and pop from the tail when forwarding a +// reply DOWN. When the path is empty on an "R" message, the current node is the +// final recipient and the remaining frame is the body. +// + +// The composite routing key. Device serials are NOT globally unique (emulators +// share e.g. "emulator-5554"), but (providerName, serial) is unique +// cluster-wide because a provider and all its devices are pinned to one +// processor and serials are unique within a single provider/host. +// +// NUL is used as the separator because it cannot appear in a providerName or +// serial. +const KEY_SEPARATOR = '\u0000' + +export const deviceKey = (providerName: string, serial: string): string => + providerName + KEY_SEPARATOR + serial + +export const KIND = { + DEVICE: 'D', + BROADCAST: 'B', + REPLY: 'R', + // EVENT: a device-originated message headed for its processor. A device + // only ever speaks to its processor — intros, heartbeats, status updates + // and group events all travel up as a single "E" kind. The processor + // decides (by message type) whether to consume it (dbapi) or forward it to + // the app side as a broadcast. Devices know nothing about the app-side + // broadcast/proxy topology. + EVENT: 'E', + // Control plane (unit <-> proxy), no Envelope body: + // ANNOUNCE: processor tells the proxy it owns a providerName. + ANNOUNCE: 'A', + // SUBSCRIBE_BROADCAST: a unit (websocket, log) registers to receive + // broadcast-to-app events. + SUBSCRIBE_BROADCAST: 'S', + // HELLO: a processor identifies itself to the proxy exactly once, right + // after its DEALER connects. Its ONLY purpose is to let the proxy tell a + // processor apart from an app-side unit (api, websocket) so it can pick the + // first processor to connect and hand it the startup INIT below. It is not + // a heartbeat and is never repeated — the provider table is still built + // from ANNOUNCE. + HELLO: 'H', + // INIT: the proxy's reply to the FIRST HELLO of its lifetime, carrying the + // proxy's own start timestamp. The receiving processor compares it against + // its own start time to decide whether it outlived the proxy and therefore + // owns the startup presence reconciliation sweep. Sent to exactly one + // processor, exactly once per proxy lifetime. + INIT: 'I', +} as const + +// Correlation ids are minted as `txn_` by the transaction manager and +// carried in the Envelope.channel slot. This prefix is the single source of +// truth shared between the sender (transmanager) and the device wire logic. +export const CORRELATION_PREFIX = 'txn_' + +// The broadcast selector meaning "all app-side receivers". Empty because the +// proxy fans a broadcast out to every registered receiver regardless. +export const BROADCAST_ALL = '' + +export type Frames = Buffer[] + +// Pre-allocated single-byte Buffers for each kind constant. Buffer.from() +// allocates a new object on every call; on the hot path (every heartbeat, every +// device event) that adds up to measurable GC pressure in the minor heap. +// These are module-level singletons — they are never mutated, only read by ZMQ. +const KIND_D = Buffer.from(KIND.DEVICE) +const KIND_E = Buffer.from(KIND.EVENT) +const KIND_R = Buffer.from(KIND.REPLY) +const KIND_B = Buffer.from(KIND.BROADCAST) +const KIND_A = Buffer.from(KIND.ANNOUNCE) +const KIND_H = Buffer.from(KIND.HELLO) +const KIND_I = Buffer.from(KIND.INIT) +// Reusable empty selector buffer for the common BROADCAST_ALL case. +const SELECTOR_ALL = Buffer.from(BROADCAST_ALL) + +// Built by app-side (api) when addressing a command to a device. The reply-path +// starts empty; each intermediate node (proxy, processor) pushes its incoming +// routingId as it forwards up. +export const encodeDeviceFrame = ( + providerName: string, + serial: string, + envelope: Buffer +): Frames => [ + KIND_D, + Buffer.from(providerName), + Buffer.from(serial), + envelope, +] + +// A device-originated event for its processor: ["E", envelope]. The device +// never addresses the app directly; the processor consumes or forwards. +export const encodeEvent = (envelope: Buffer): Frames => [ + KIND_E, + envelope, +] + +// A reply travelling back down a reply-path: ["R", ...replyPath, envelope]. +export const encodeReply = (replyPath: Frames, envelope: Buffer): Frames => [ + KIND_R, + ...replyPath, + envelope, +] + +// A control-plane announce (processor -> proxy): ["A", providerName]. +export const encodeAnnounce = (providerName: string): Frames => [ + KIND_A, + Buffer.from(providerName), +] + +// A one-shot processor->proxy identification frame: ["H"]. Sent once per +// processor lifetime, immediately after the DEALER connects. +export const encodeHello = (): Frames => [ + KIND_H, +] + +// The proxy's one-shot startup handoff: ["I", startedAt]. startedAt is the +// proxy's process start time as decimal epoch-millis — a plain string frame, +// because the proxy/processor control plane carries no Envelope body. +export const encodeInit = (startedAt: number): Frames => [ + KIND_I, + Buffer.from(String(startedAt)), +] + +// A broadcast-to-app message: ["B", selector, envelope]. The selector defaults +// to BROADCAST_ALL (fan out to every registered receiver). +export const encodeBroadcast = ( + envelope: Buffer, + selector: string = BROADCAST_ALL +): Frames => [ + KIND_B, + selector === BROADCAST_ALL ? SELECTOR_ALL : Buffer.from(selector), + envelope, +] + +export interface ClassifiedFrames { + // The sender routing identity when the frames came off a ROUTER (which + // prepends it), else null (a DEALER strips it). + sender: Buffer | null + // The protocol kind (frame 0 of the message). + kind: string + // The protocol message WITHOUT the ROUTER-added identity, i.e. starting at + // the kind frame: [kind, ...rest, body]. This is what every route*() + // function consumes. + message: Frames +} + +// Classify one inbound multipart. The single axis that differs between call +// sites is whether a ROUTER prepended the sender identity as frame 0. Unifying +// it here removes the off-by-one slice differences that recur across the proxy, +// processor and device routing code. +export const classifyFrames = ( + frames: Frames, + {routerPrepended}: {routerPrepended: boolean} +): ClassifiedFrames => { + const sender = routerPrepended ? frames[0] : null + const message = routerPrepended ? frames.slice(1) : frames + return {sender, kind: message[0].toString(), message} +} + +// Push the caller's incoming routingId onto the tail of the reply-path stack. +// The body is always the last frame, so the new id is inserted just before it. +// This is kind-agnostic: it only touches the tail of the array. +export const pushReplyPath = (frames: Frames, routingId: Buffer): Frames => { + const out = frames.slice() + out.splice(out.length - 1, 0, routingId) + return out +} + +export interface PoppedReply { + // The routingId to send the reply to next, or null when this node is the + // final recipient (reply-path exhausted). + routingId: Buffer | null + // The reply frames to forward on, with the popped id removed. + frames: Frames + // The Envelope body (always the last frame). + body: Buffer + // True when the reply-path was empty: this node is the final recipient. + final: boolean +} + +// Pop the tail routingId off a reply ("R") message. Reply layout is +// ["R", ...replyPath, body]; the tail id (frame before the body) is the newest +// pushed, so it comes off first (LIFO). When only ["R", body] remains the path +// is exhausted and the current node is the final recipient. +export const popReplyPath = (frames: Frames): PoppedReply => { + const body = frames[frames.length - 1] + // frame 0 is the "R" kind; a reply-path frame exists only when there are + // more than 2 frames (kind + at least one id + body). + if (frames.length <= 2) { + return {routingId: null, frames, body, final: true} + } + const routingId = frames[frames.length - 2] + const out = frames.slice(0, -2) + out.push(body) + return { + routingId, + frames: out, + body, + final: false, + } +} diff --git a/lib/wire/router.ts b/lib/wire/router.ts index 10331747cb..eb236081b7 100644 --- a/lib/wire/router.ts +++ b/lib/wire/router.ts @@ -69,8 +69,8 @@ export class WireRouter { } const messageType = this.registeredTypes.get(target); if (!messageType) { - // log.warn(`Unknown message type:`, decoded) - // Nobody is expecting such message type.. Ignoring.. + // log.warn(`Unknown message type:`, decoded) + // Nobody is expecting such message type.. Ignoring.. return } const decodedMessage = Any.unpack(decoded.message, messageType) diff --git a/lib/wire/seqqueue.js b/lib/wire/seqqueue.js deleted file mode 100644 index a3588b9ae5..0000000000 --- a/lib/wire/seqqueue.js +++ /dev/null @@ -1,56 +0,0 @@ -function SeqQueue(size, maxWaiting) { - this.lo = 0 - this.size = size - this.maxWaiting = maxWaiting - this.waiting = 0 - this.list = new Array(size) - this.locked = true -} -SeqQueue.prototype.start = function(seq) { - this.locked = false - // The loop in maybeConsume() will make sure that the value wraps correctly - // if necessary. - this.lo = seq + 1 - this.maybeConsume() -} -SeqQueue.prototype.stop = function() { - this.locked = true - this.maybeConsume() -} -SeqQueue.prototype.push = function(seq, handler) { - if (seq >= this.size) { - return - } - this.list[seq] = handler - this.waiting += 1 - this.maybeConsume() -} -SeqQueue.prototype.maybeConsume = function() { - if (this.locked) { - return - } - while (this.waiting) { - // Did we reach the end of the loop? If so, start from the beginning. - if (this.lo >= this.size) { - this.lo = 0 - } - var handler = this.list[this.lo] - // Have we received it yet? - if (handler) { - this.list[this.lo] = undefined - handler() - this.lo += 1 - this.waiting -= 1 - } - // Are we too much behind? If so, just move on. - else if (this.waiting >= this.maxWaiting) { - this.lo += 1 - this.waiting -= 1 - } - // We don't have it yet, stop. - else { - break - } - } -} -export default SeqQueue diff --git a/lib/wire/seqqueue.ts b/lib/wire/seqqueue.ts new file mode 100644 index 0000000000..caad89000f --- /dev/null +++ b/lib/wire/seqqueue.ts @@ -0,0 +1,87 @@ +type Handler = () => void + +// SeqQueue is a fixed-size reorder buffer keyed by a cyclic sequence number. +// It exists on the device touch path: the browser mints a cyclic `seq` +// (0..size-1) per input event and events may arrive out of order over the +// wire. SeqQueue replays their handlers in strict `seq` order, tolerating up +// to `maxWaiting` missing slots before skipping a gap so it never stalls. +export default class SeqQueue { + private readonly size: number + private readonly maxWaiting: number + private readonly list: Array + private lo = 0 + private waiting = 0 + private locked = true + + constructor(size: number, maxWaiting: number) { + this.size = size + this.maxWaiting = maxWaiting + this.list = new Array(size) + } + + start(seq: number): void { + this.locked = false + // The loop in maybeConsume() wraps `lo` correctly if seq + 1 === size. + this.lo = seq + 1 + this.maybeConsume() + } + + stop(): void { + this.locked = true + this.maybeConsume() + } + + push(seq: number, handler: Handler): void { + if (seq >= this.size) { + return + } + this.list[seq] = handler + this.waiting += 1 + this.maybeConsume() + } + + maybeConsume(): void { + if (this.locked) { + return + } + // Hoist fields into locals for the hot loop. + const {list, size, maxWaiting} = this + let lo = this.lo + let waiting = this.waiting + while (waiting) { + // Reached the end of the ring? Wrap back to the beginning. + if (lo >= size) { + lo = 0 + } + const handler = list[lo] + if (handler) { + // Have it: deliver in order. + list[lo] = undefined + lo += 1 + waiting -= 1 + // Persist cursor before invoking, so a re-entrant stop()/start() + // from within the handler observes consistent state. + this.lo = lo + this.waiting = waiting + handler() + if (this.locked) { + return + } + // The handler may have mutated lo/waiting (e.g. via start()). + lo = this.lo + waiting = this.waiting + } + else if (waiting >= maxWaiting) { + // Too far behind waiting for a missing seq: skip the gap. + lo += 1 + waiting -= 1 + } + else { + // Don't have it yet, and still within tolerance: wait. + break + } + } + this.lo = lo + this.waiting = waiting + } +} diff --git a/lib/wire/transmanager.ts b/lib/wire/transmanager.ts index 27dc0eb88a..2a328f85bc 100644 --- a/lib/wire/transmanager.ts +++ b/lib/wire/transmanager.ts @@ -1,122 +1,143 @@ import {v4 as uuidv4} from 'uuid' +import {MessageType} from '@protobuf-ts/runtime' +import * as Sentry from '@sentry/node' import apiutil from '../util/apiutil.js' -import {TransactionDoneMessage} from './wire.js' import {WireRouter} from './router.js' -import * as Sentry from '@sentry/node' -import wireutil from './util.js' -import type {SocketWrapper} from '../util/zmqutil.js' -import EventEmitter from 'events' -import {MessageType} from '@protobuf-ts/runtime' +import {TransactionDoneMessage, TransactionProgressMessage, Envelope} from './wire.js' +import {Any} from './google/protobuf/any.js' +import {CORRELATION_PREFIX} from './frame.js' +import type {AppTransport} from './app-transport.js' + +// The one Envelope encoder transmanager needs (channel-in-slot for reply +// correlation); inlined to avoid importing the whole wireutil object. +const tr = (channel: string, messageType: MessageType, message: T): Uint8Array => + Envelope.toBinary({message: Any.pack(message, messageType), channel}) -const sentryTransactionSpan = >(channel: string, message: any, timeout: number, cb: () => T): T => +const sentryTransactionSpan = >(target: string, message: any, timeout: number, cb: () => T): T => Sentry.startSpan({ op: 'wireTransaction', - name: message.$code, - attributes: { - message, - channel, - timeout - }, + name: message?.$code, + attributes: {message, target, timeout}, forceTransaction: true, }, cb) -const sentryCaptureTimeout = (channel: string, message: any, timeout: number) => { +const sentryCaptureTimeout = (target: string, message: any, timeout: number) => { Sentry.addBreadcrumb({ - data: {channel, message, timeout}, + data: {target, message, timeout}, message: 'Transaction context', level: 'warning', - type: 'default' + type: 'default', }) Sentry.captureMessage('Timeout when running transaction') } -interface TransactionTransportOptions { - sub: SocketWrapper - push: SocketWrapper - channelRouter: EventEmitter - timeout?: number +// The value a transaction settles with: the reply's success flag, its `data` +// scalar (e.g. a connect url / install result string) and its parsed JSON +// `body` (an object, or {} when absent). +export interface TransactionResult { + success: boolean + data?: string + body: any } -export const runTransaction = (channel: string, messageType: MessageType, message: T, {sub, push, channelRouter, timeout = apiutil.GRPC_WAIT_TIMEOUT}: TransactionTransportOptions) => - sentryTransactionSpan( - channel, - message, - timeout, - () => { - const responseChannel = 'txn_' + uuidv4() - sub.subscribe(responseChannel) - return new Promise((resolve, reject) => { - const messageListener = new WireRouter() - .on(TransactionDoneMessage, (channel: string, message: any) => { - clearTimeout(trTimeout) - sub.unsubscribe(responseChannel) - channelRouter.removeListener(responseChannel, messageListener) - if (message.success) { - resolve(message) - } - else { - reject(message) - } - }) - .handler() - - const trTimeout = setTimeout(function() { - channelRouter.removeListener(responseChannel, messageListener) - sub.unsubscribe(responseChannel) - - sentryCaptureTimeout(channel, message, timeout) - reject(new Error('Timeout when running transaction')) - }, timeout) +// A progress callback fires for every TransactionProgressMessage a device +// streams before the final done (shell output chunks, install progress). `data` +// is the chunk/status, `progress` a 0..100 percentage, `seq` its order. +export type ProgressCallback = (data: string | undefined, progress: number | undefined, seq: number) => void + +interface PendingTransaction { + resolve: (result: TransactionResult) => void + reject: (result: TransactionResult) => void + timer: ReturnType + onProgress?: ProgressCallback +} + +// Owns request/reply over a single app-side AppTransport (DEALER to the proxy). +// +// A device command is addressed by (providerName, serial): the AppTransport +// encodes it as a [D, providerName, serial, envelope] frame and the proxy +// forwards it to the processor owning that provider. A correlationId +// (`txn_`) is minted per transaction and carried in the Envelope.channel +// slot. It is used ONLY to match a reply back to its pending promise — NOT for +// routing (routing is done by the frame-protocol reply-path). One shared +// handler on the transport dispatches every reply (delivered as the transport's +// 'message' event with channel = Envelope.channel) to the correct pending +// entry, instead of attaching a listener per transaction. +export class TransactionManager { + private pending = new Map() - channelRouter.on(responseChannel, messageListener) - push.send([ - channel, - wireutil.tr(responseChannel, messageType, message) - ]) + constructor(private transport: AppTransport) { + const router = new WireRouter() + .on(TransactionProgressMessage, (correlationId: string, message: any) => { + this.pending.get(correlationId)?.onProgress?.( + message.data, message.progress, message.seq + ) + }) + .on(TransactionDoneMessage, (correlationId: string, message: any) => { + this.settle(correlationId, message) }) + .handler() + + // AppTransport emits 'message' (channel, body) for a reply (R); channel + // is the Envelope.channel (the correlationId), which WireRouter also + // reads, so this is the single reply dispatch point. + this.transport.on('message', router) + } + + private settle(correlationId: string, message: any) { + const entry = this.pending.get(correlationId) + if (!entry) { + return } - ) + clearTimeout(entry.timer) + this.pending.delete(correlationId) -type TransactionDevTransportOptions = Omit & { - router: WireRouter -} + // A TransactionDoneMessage carries two payload channels: `data` (a short + // scalar — e.g. a connect url or install result string) and `body` (an + // optional JSON blob). Surface both, plus `success`, so callers can pick + // whichever their transaction produces. + const result = { + success: message.success, + data: message.data, + body: message.body ? JSON.parse(message.body) : {}, + } + if (message.success) { + entry.resolve(result) + } + else { + entry.reject(result) + } + } + + runTransaction( + providerName: string, + serial: string, + messageType: MessageType, + message: T, + {timeout = apiutil.GRPC_WAIT_TIMEOUT, onProgress}: {timeout?: number; onProgress?: ProgressCallback} = {} + ): Promise { + const target = providerName + '/' + serial + return sentryTransactionSpan(target, message, timeout, () => { + const correlationId = CORRELATION_PREFIX + uuidv4() -export const runTransactionDev = (channel: string, messageType: MessageType, message: T, {sub, push, router, timeout = apiutil.GRPC_WAIT_TIMEOUT}: TransactionDevTransportOptions): Promise => - sentryTransactionSpan( - channel, - message, - timeout, - () => { - const responseChannel = 'txn_' + uuidv4() - sub.subscribe(responseChannel) return new Promise((resolve, reject) => { - const messageListener = (channel: string, message: any) => { - clearTimeout(trTimeout) - sub.unsubscribe(responseChannel) - router.removeListener(TransactionDoneMessage, messageListener) - - const body = message.body ? JSON.parse(message.body) : {} - if (message.success) { - resolve(body) - } - else { - reject(body) - } - } - router.on(TransactionDoneMessage, messageListener) - - const trTimeout = setTimeout(() => { - router.removeListener(TransactionDoneMessage, messageListener) - sub.unsubscribe(responseChannel) - - sentryCaptureTimeout(channel, message, timeout) + const timer = setTimeout(() => { + this.pending.delete(correlationId) + sentryCaptureTimeout(target, message, timeout) reject(new Error('Timeout when running transaction')) }, timeout) - push.send([ - channel, - wireutil.tr(responseChannel, messageType, message) - ]) + this.pending.set(correlationId, {resolve, reject, timer, onProgress}) + + // The Envelope carries the correlationId in its channel slot; + // AppTransport encodes it as a device-directed frame. + this.transport.sendCommand( + providerName, + serial, + tr(correlationId, messageType, message) + ) }) - } - ) + }) + } +} + diff --git a/lib/wire/util.ts b/lib/wire/util.ts index 748c4b04d7..242b1b857b 100644 --- a/lib/wire/util.ts +++ b/lib/wire/util.ts @@ -90,8 +90,12 @@ const wireutil = { body: body ? JSON.stringify(body) : undefined }) }, - progress(data: string, progress: number) { - if (!Number.isInteger(progress)) { + progress(data: string, progress = 0) { + if (!Number.isFinite(progress)) { + log.warn('Somebody is sending non finite progress: %s', data) + progress = 0 + } + else if (!Number.isInteger(progress)) { log.warn('Somebody is sending non integer as progress: %s', data) progress = Math.round(progress) } diff --git a/lib/wire/wire.proto b/lib/wire/wire.proto index c213f71719..6a1c0dace4 100644 --- a/lib/wire/wire.proto +++ b/lib/wire/wire.proto @@ -299,10 +299,12 @@ message DeviceRegisteredMessage { message DevicePresentMessage { required string serial = 1; + optional double presenceChangedAt = 2; } message DeviceAbsentMessage { required string serial = 1; + optional double presenceChangedAt = 2; } message DeviceReadyMessage { diff --git a/lib/wire/wire.ts b/lib/wire/wire.ts index 563e3f9ac2..f944c06afe 100644 --- a/lib/wire/wire.ts +++ b/lib/wire/wire.ts @@ -853,6 +853,10 @@ export interface DevicePresentMessage { * @generated from protobuf field: required string serial = 1 */ serial: string; + /** + * @generated from protobuf field: optional double presenceChangedAt = 2 + */ + presenceChangedAt?: number; } /** * @generated from protobuf message DeviceAbsentMessage @@ -862,6 +866,10 @@ export interface DeviceAbsentMessage { * @generated from protobuf field: required string serial = 1 */ serial: string; + /** + * @generated from protobuf field: optional double presenceChangedAt = 2 + */ + presenceChangedAt?: number; } /** * @generated from protobuf message DeviceReadyMessage @@ -5374,7 +5382,8 @@ export const DeviceRegisteredMessage = new DeviceRegisteredMessage$Type(); class DevicePresentMessage$Type extends MessageType { constructor() { super("DevicePresentMessage", [ - { no: 1, name: "serial", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + { no: 1, name: "serial", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "presenceChangedAt", kind: "scalar", opt: true, T: 1 /*ScalarType.DOUBLE*/ } ]); } create(value?: PartialMessage): DevicePresentMessage { @@ -5392,6 +5401,9 @@ class DevicePresentMessage$Type extends MessageType { case /* required string serial */ 1: message.serial = reader.string(); break; + case /* optional double presenceChangedAt */ 2: + message.presenceChangedAt = reader.double(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -5407,6 +5419,9 @@ class DevicePresentMessage$Type extends MessageType { /* required string serial = 1; */ if (message.serial !== "") writer.tag(1, WireType.LengthDelimited).string(message.serial); + /* optional double presenceChangedAt = 2; */ + if (message.presenceChangedAt !== undefined) + writer.tag(2, WireType.Bit64).double(message.presenceChangedAt); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); @@ -5421,7 +5436,8 @@ export const DevicePresentMessage = new DevicePresentMessage$Type(); class DeviceAbsentMessage$Type extends MessageType { constructor() { super("DeviceAbsentMessage", [ - { no: 1, name: "serial", kind: "scalar", T: 9 /*ScalarType.STRING*/ } + { no: 1, name: "serial", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "presenceChangedAt", kind: "scalar", opt: true, T: 1 /*ScalarType.DOUBLE*/ } ]); } create(value?: PartialMessage): DeviceAbsentMessage { @@ -5439,6 +5455,9 @@ class DeviceAbsentMessage$Type extends MessageType { case /* required string serial */ 1: message.serial = reader.string(); break; + case /* optional double presenceChangedAt */ 2: + message.presenceChangedAt = reader.double(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -5454,6 +5473,9 @@ class DeviceAbsentMessage$Type extends MessageType { /* required string serial = 1; */ if (message.serial !== "") writer.tag(1, WireType.LengthDelimited).string(message.serial); + /* optional double presenceChangedAt = 2; */ + if (message.presenceChangedAt !== undefined) + writer.tag(2, WireType.Bit64).double(message.presenceChangedAt); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); diff --git a/package-lock.json b/package-lock.json index 72c2916a66..a50c447e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vk-devicehub", - "version": "1.5.0", + "version": "1.5.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vk-devicehub", - "version": "1.5.0", + "version": "1.5.3", "hasInstallScript": true, "dependencies": { "@aws-sdk/client-s3": "^3.772.0", @@ -24,7 +24,7 @@ "@julusian/jpeg-turbo": "2.1.0", "@node-saml/passport-saml": "5.1.0", "@protobuf-ts/plugin": "^2.11.1", - "@sentry/node": "^8.34.0", + "@sentry/node": "^10.62.0", "@types/chrome-remote-interface": "^0.31.14", "@types/cookie-parser": "^1.4.10", "@u4/adbkit": "^5.1.7", @@ -103,13 +103,13 @@ "tsx": "4.20.3", "underscore.string": "3.3.6", "url-join": "1.1.0", - "usb-hotplug": "^0.1.6", + "usb-hotplug": "^1.0.0", "utf-8-validate": "5.0.9", "uuid": "^11.0.3", "webpack": "^5.88.1", "ws": "5.2.4", "yaml": "2.4.2", - "yargs": "^17.7.2", + "yargs": "^18.0.0", "yauzl": "2.10.0", "zeromq": "6.4.2" }, @@ -147,7 +147,8 @@ "http-https": "1.0.0", "imports-loader": "^4.0.1", "typescript": "^5.9.2", - "typescript-eslint": "^8.39.1" + "typescript-eslint": "^8.39.1", + "vitest": "^4.1.10" }, "engines": { "node": ">= 12", @@ -159,6 +160,78 @@ "version": "2.7.0", "license": "Apache-2.0" }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", + "integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", + "integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT" + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz", + "integrity": "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, + "node_modules/@apm-js-collab/tracing-hooks/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/@apm-js-collab/tracing-hooks/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/@appium/base-driver": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@appium/base-driver/-/base-driver-10.2.0.tgz", @@ -2311,9 +2384,9 @@ "license": "CC-BY-SA-4.0" }, "node_modules/@e-khalilov/usb-hotplug-darwin-arm64": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-darwin-arm64/-/usb-hotplug-darwin-arm64-0.1.6.tgz", - "integrity": "sha512-Z4ev7qhFeWReb6xONTMmkhkV13tATbbHbjPIki7nSmf4pfbfa0AUvMW7dcaaagyUNzBjib4w0UuW9LykmHKcGQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-darwin-arm64/-/usb-hotplug-darwin-arm64-1.0.0.tgz", + "integrity": "sha512-t+xvHfZaYxR7yRNcc5srM+aBFO6yT1VTve+rAXMyblaZrW6gBlJrPnIKZpsWwBS8RQ4Ydv/oh5QM5OBFIDdK9A==", "cpu": [ "arm64" ], @@ -2327,9 +2400,9 @@ } }, "node_modules/@e-khalilov/usb-hotplug-darwin-x64": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-darwin-x64/-/usb-hotplug-darwin-x64-0.1.6.tgz", - "integrity": "sha512-Vyyw2BQT5fz/moM6fEcARzK+QPnugOrnQsEcLinIL2+hZMrWJOKloCo+oesiZoYuOaC4L6/DRvW59w/WGvO1aA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-darwin-x64/-/usb-hotplug-darwin-x64-1.0.0.tgz", + "integrity": "sha512-dS0LW9nDVVJM+MxqD+mopU/CCYFG+1cyzR6jElVxq5ssPB8dTyMqKgZaZjma5xCeECG6zPPiNRMozj4DPGDT0A==", "cpu": [ "x64" ], @@ -2343,9 +2416,9 @@ } }, "node_modules/@e-khalilov/usb-hotplug-linux-x64-gnu": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-linux-x64-gnu/-/usb-hotplug-linux-x64-gnu-0.1.6.tgz", - "integrity": "sha512-a/od34AiFQxO+wDOHfhqG9BrUNKUqhQ19SbrwdzFTFBQ5yoLk3JR7Iz1MuUoxwUSi7sl7vh/tYNd/NzYwXSKRg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-linux-x64-gnu/-/usb-hotplug-linux-x64-gnu-1.0.0.tgz", + "integrity": "sha512-Z9CYBqcK6fl3Hl1Yx+dbwnig/6BBLmFplsb6VgZLwOHV42CN51glkSdvCzJxEbnFoIrpfEOvEKSpVM+cLO/L8w==", "cpu": [ "x64" ], @@ -2359,9 +2432,9 @@ } }, "node_modules/@e-khalilov/usb-hotplug-win32-x64-msvc": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-win32-x64-msvc/-/usb-hotplug-win32-x64-msvc-0.1.6.tgz", - "integrity": "sha512-X3Wpc4D/eGrQx3+DdKGokeg+v0YzxPsnYcI4o7bS7r/Q9qxLMc9HuV2qtw8gQ9c2eDpnVamB3aai8dj+7ZgYZA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@e-khalilov/usb-hotplug-win32-x64-msvc/-/usb-hotplug-win32-x64-msvc-1.0.0.tgz", + "integrity": "sha512-PbEwS/1GOVRZ1U30FvRORiyfAjsPy/SwN1FPJIJv50NVLnsINFPSem+YE0WIiUYPp9Gr89d5TOELWN0jTMUjpQ==", "cpu": [ "x64" ], @@ -2374,644 +2447,759 @@ "node": ">=16" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.8", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", - "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ - "arm64" + "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "aix" ], + "peer": true, "engines": { "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">=18" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">=18" } }, - "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.6", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">=18" } }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@eslint/js": { - "version": "9.33.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", - "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": ">=18" } }, - "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@eslint/plugin-kit": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", - "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.15.1", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=18" } }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=18.18.0" + "node": ">=18" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">=18" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, + "os": [ + "linux" + ], + "peer": true, "engines": { "node": ">=18" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], + "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], - "license": "Apache-2.0", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "netbsd" ], + "peer": true, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "darwin" + "openbsd" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ - "arm" + "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ - "arm64" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "sunos" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ - "ppc64" + "arm64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ - "riscv64" + "ia32" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ - "s390x" + "x64" ], - "license": "LGPL-3.0-or-later", + "dev": true, + "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], - "funding": { - "url": "https://opencollective.com/libvips" + "peer": true, + "engines": { + "node": ">=18" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, "funding": { - "url": "https://opencollective.com/libvips" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], + "node_modules/@eslint/core": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "dev": true, "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "dependencies": { + "@types/json-schema": "^7.0.15" }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "url": "https://opencollective.com/eslint" } }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=18" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "node_modules/@eslint/js": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "dev": true, + "license": "MIT", "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "url": "https://eslint.org/donate" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@img/sharp-linuxmusl-x64": { + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ - "x64" + "arm64" ], "license": "Apache-2.0", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -3020,1531 +3208,1464 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, - "node_modules/@img/sharp-wasm32": { + "node_modules/@img/sharp-darwin-x64": { "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ - "wasm32" + "x64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "license": "Apache-2.0", "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, + "os": [ + "darwin" + ], "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "darwin" ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ - "ia32" + "x64" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "darwin" ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ - "x64" + "arm" ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", + "license": "LGPL-3.0-or-later", "optional": true, "os": [ - "win32" + "linux" ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, "funding": { "url": "https://opencollective.com/libvips" } }, - "node_modules/@irdk/usbmux": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@irdk/usbmux/-/usbmux-0.2.2.tgz", - "integrity": "sha512-BU4xSxBw2NGhv8NVxMsv0CNVgbm7DFTjOmCUF15M4BTM8PAyl07vDr8nLxdCtwKfaKhFOjGCsHP+MDwSZ+fLug==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.6", - "plist": "^3.1.0", - "yargs": "^17.7.2" - }, - "bin": { - "irelay": "bin/cli.js" - } - }, - "node_modules/@irdk/usbmux/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@irdk/usbmux/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/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "license": "MIT", - "engines": { - "node": ">=12" - }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/libvips" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" } }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, - "node_modules/@jimp/bmp": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.22.12.tgz", - "integrity": "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12", - "bmp-js": "^0.1.0" + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, - "node_modules/@jimp/core": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.22.12.tgz", - "integrity": "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12", - "any-base": "^1.1.0", - "buffer": "^5.2.0", - "exif-parser": "^0.1.12", - "file-type": "^16.5.4", - "isomorphic-fetch": "^3.0.0", - "pixelmatch": "^4.0.2", - "tinycolor2": "^1.6.0" + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, - "node_modules/@jimp/custom": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", - "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", - "license": "MIT", - "dependencies": { - "@jimp/core": "^0.22.12" + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" } }, - "node_modules/@jimp/gif": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.22.12.tgz", - "integrity": "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12", - "gifwrap": "^0.10.1", - "omggif": "^1.0.9" + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, - "node_modules/@jimp/jpeg": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.22.12.tgz", - "integrity": "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12", - "jpeg-js": "^0.4.4" + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, - "node_modules/@jimp/plugin-blit": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", - "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", - "license": "MIT", + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, "dependencies": { - "@jimp/utils": "^0.22.12" + "@emnapi/runtime": "^1.7.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jimp/plugin-blur": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", - "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12" + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jimp/plugin-circle": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.22.12.tgz", - "integrity": "sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12" + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jimp/plugin-color": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", - "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", - "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12", - "tinycolor2": "^1.6.0" + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@jimp/plugin-contain": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.22.12.tgz", - "integrity": "sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ==", + "node_modules/@irdk/usbmux": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@irdk/usbmux/-/usbmux-0.2.2.tgz", + "integrity": "sha512-BU4xSxBw2NGhv8NVxMsv0CNVgbm7DFTjOmCUF15M4BTM8PAyl07vDr8nLxdCtwKfaKhFOjGCsHP+MDwSZ+fLug==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "debug": "^4.3.6", + "plist": "^3.1.0", + "yargs": "^17.7.2" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-blit": ">=0.3.5", - "@jimp/plugin-resize": ">=0.3.5", - "@jimp/plugin-scale": ">=0.3.5" + "bin": { + "irelay": "bin/cli.js" } }, - "node_modules/@jimp/plugin-cover": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.22.12.tgz", - "integrity": "sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA==", + "node_modules/@irdk/usbmux/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", - "dependencies": { - "@jimp/utils": "^0.22.12" - }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-crop": ">=0.3.5", - "@jimp/plugin-resize": ">=0.3.5", - "@jimp/plugin-scale": ">=0.3.5" + "engines": { + "node": ">=8" } }, - "node_modules/@jimp/plugin-crop": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", - "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", - "license": "MIT", + "node_modules/@irdk/usbmux/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": { - "@jimp/utils": "^0.22.12" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": ">=12" } }, - "node_modules/@jimp/plugin-displace": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.22.12.tgz", - "integrity": "sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA==", + "node_modules/@irdk/usbmux/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "ms": "^2.1.3" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@jimp/plugin-dither": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.22.12.tgz", - "integrity": "sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw==", + "node_modules/@irdk/usbmux/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/@irdk/usbmux/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": { - "@jimp/utils": "^0.22.12" + "ansi-regex": "^5.0.1" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": ">=8" } }, - "node_modules/@jimp/plugin-fisheye": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.22.12.tgz", - "integrity": "sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q==", + "node_modules/@irdk/usbmux/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "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" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": ">=12" } }, - "node_modules/@jimp/plugin-flip": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.22.12.tgz", - "integrity": "sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q==", + "node_modules/@irdk/usbmux/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/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12" - }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-rotate": ">=0.3.5" + "engines": { + "node": "20 || >=22" } }, - "node_modules/@jimp/plugin-gaussian": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.22.12.tgz", - "integrity": "sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg==", + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "@isaacs/balanced-match": "^4.0.1" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": "20 || >=22" } }, - "node_modules/@jimp/plugin-invert": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.22.12.tgz", - "integrity": "sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ==", - "license": "MIT", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", "dependencies": { - "@jimp/utils": "^0.22.12" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": ">=12" } }, - "node_modules/@jimp/plugin-mask": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.22.12.tgz", - "integrity": "sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA==", + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12" + "engines": { + "node": ">=12" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@jimp/plugin-normalize": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.22.12.tgz", - "integrity": "sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA==", + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "license": "MIT", - "dependencies": { - "@jimp/utils": "^0.22.12" + "engines": { + "node": ">=12" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@jimp/plugin-print": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.22.12.tgz", - "integrity": "sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ==", + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12", - "load-bmfont": "^1.4.1" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-blit": ">=0.3.5" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@jimp/plugin-resize": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", - "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "ansi-regex": "^6.0.1" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@jimp/plugin-rotate": { - "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", - "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-blit": ">=0.3.5", - "@jimp/plugin-crop": ">=0.3.5", - "@jimp/plugin-resize": ">=0.3.5" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/@jimp/plugin-scale": { + "node_modules/@jimp/bmp": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", - "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", + "resolved": "https://registry.npmjs.org/@jimp/bmp/-/bmp-0.22.12.tgz", + "integrity": "sha512-aeI64HD0npropd+AR76MCcvvRaa+Qck6loCOS03CkkxGHN5/r336qTM5HPUdHKMDOGzqknuVPA8+kK1t03z12g==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" + "@jimp/utils": "^0.22.12", + "bmp-js": "^0.1.0" }, "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-resize": ">=0.3.5" + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@jimp/plugin-shadow": { + "node_modules/@jimp/core": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.22.12.tgz", - "integrity": "sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg==", + "resolved": "https://registry.npmjs.org/@jimp/core/-/core-0.22.12.tgz", + "integrity": "sha512-l0RR0dOPyzMKfjUW1uebzueFEDtCOj9fN6pyTYWWOM/VS4BciXQ1VVrJs8pO3kycGYZxncRKhCoygbNr8eEZQA==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" - }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-blur": ">=0.3.5", - "@jimp/plugin-resize": ">=0.3.5" + "@jimp/utils": "^0.22.12", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "exif-parser": "^0.1.12", + "file-type": "^16.5.4", + "isomorphic-fetch": "^3.0.0", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.6.0" } }, - "node_modules/@jimp/plugin-threshold": { + "node_modules/@jimp/custom": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.22.12.tgz", - "integrity": "sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw==", + "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", + "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", "dependencies": { - "@jimp/utils": "^0.22.12" - }, - "peerDependencies": { - "@jimp/custom": ">=0.3.5", - "@jimp/plugin-color": ">=0.8.0", - "@jimp/plugin-resize": ">=0.8.0" + "@jimp/core": "^0.22.12" } }, - "node_modules/@jimp/plugins": { + "node_modules/@jimp/gif": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.22.12.tgz", - "integrity": "sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww==", + "resolved": "https://registry.npmjs.org/@jimp/gif/-/gif-0.22.12.tgz", + "integrity": "sha512-y6BFTJgch9mbor2H234VSjd9iwAhaNf/t3US5qpYIs0TSbAvM02Fbc28IaDETj9+4YB4676sz4RcN/zwhfu1pg==", "license": "MIT", "dependencies": { - "@jimp/plugin-blit": "^0.22.12", - "@jimp/plugin-blur": "^0.22.12", - "@jimp/plugin-circle": "^0.22.12", - "@jimp/plugin-color": "^0.22.12", - "@jimp/plugin-contain": "^0.22.12", - "@jimp/plugin-cover": "^0.22.12", - "@jimp/plugin-crop": "^0.22.12", - "@jimp/plugin-displace": "^0.22.12", - "@jimp/plugin-dither": "^0.22.12", - "@jimp/plugin-fisheye": "^0.22.12", - "@jimp/plugin-flip": "^0.22.12", - "@jimp/plugin-gaussian": "^0.22.12", - "@jimp/plugin-invert": "^0.22.12", - "@jimp/plugin-mask": "^0.22.12", - "@jimp/plugin-normalize": "^0.22.12", - "@jimp/plugin-print": "^0.22.12", - "@jimp/plugin-resize": "^0.22.12", - "@jimp/plugin-rotate": "^0.22.12", - "@jimp/plugin-scale": "^0.22.12", - "@jimp/plugin-shadow": "^0.22.12", - "@jimp/plugin-threshold": "^0.22.12", - "timm": "^1.6.1" + "@jimp/utils": "^0.22.12", + "gifwrap": "^0.10.1", + "omggif": "^1.0.9" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, - "node_modules/@jimp/png": { + "node_modules/@jimp/jpeg": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.22.12.tgz", - "integrity": "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==", + "resolved": "https://registry.npmjs.org/@jimp/jpeg/-/jpeg-0.22.12.tgz", + "integrity": "sha512-Rq26XC/uQWaQKyb/5lksCTCxXhtY01NJeBN+dQv5yNYedN0i7iYu+fXEoRsfaJ8xZzjoANH8sns7rVP4GE7d/Q==", "license": "MIT", "dependencies": { "@jimp/utils": "^0.22.12", - "pngjs": "^6.0.0" + "jpeg-js": "^0.4.4" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, - "node_modules/@jimp/tiff": { + "node_modules/@jimp/plugin-blit": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.22.12.tgz", - "integrity": "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", + "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", "dependencies": { - "utif2": "^4.0.1" + "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, - "node_modules/@jimp/types": { + "node_modules/@jimp/plugin-blur": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.22.12.tgz", - "integrity": "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==", + "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", + "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", "dependencies": { - "@jimp/bmp": "^0.22.12", - "@jimp/gif": "^0.22.12", - "@jimp/jpeg": "^0.22.12", - "@jimp/png": "^0.22.12", - "@jimp/tiff": "^0.22.12", - "timm": "^1.6.1" + "@jimp/utils": "^0.22.12" }, "peerDependencies": { "@jimp/custom": ">=0.3.5" } }, - "node_modules/@jimp/utils": { + "node_modules/@jimp/plugin-circle": { "version": "0.22.12", - "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.22.12.tgz", - "integrity": "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.13.3" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.10", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", - "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@julusian/jpeg-turbo": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@julusian/jpeg-turbo/-/jpeg-turbo-2.1.0.tgz", - "integrity": "sha512-tmMnOTuiXr3W+lPOlyrG/efBxVvp/tbJe6iVsY2qKl5TLHytCkWabsnq4PHS6ddCYJeiGWQBvRwQpKlFd3I2gg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "cmake-js": "^7.0.0", - "node-addon-api": "^5.0.0", - "pkg-prebuilds": "~0.1.0" - }, - "engines": { - "node": ">=14.15" - } - }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", - "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, - "node_modules/@node-saml/node-saml": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", - "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.12", - "@types/qs": "^6.9.18", - "@types/xml-encryption": "^1.2.4", - "@types/xml2js": "^0.4.14", - "@xmldom/is-dom-node": "^1.0.1", - "@xmldom/xmldom": "^0.8.10", - "debug": "^4.4.0", - "xml-crypto": "^6.1.2", - "xml-encryption": "^3.1.0", - "xml2js": "^0.6.2", - "xmlbuilder": "^15.1.1", - "xpath": "^0.0.34" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@node-saml/node-saml/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "resolved": "https://registry.npmjs.org/@jimp/plugin-circle/-/plugin-circle-0.22.12.tgz", + "integrity": "sha512-SWVXx1yiuj5jZtMijqUfvVOJBwOifFn0918ou4ftoHgegc5aHWW5dZbYPjvC9fLpvz7oSlptNl2Sxr1zwofjTg==", "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "dependencies": { + "@jimp/utils": "^0.22.12" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@node-saml/node-saml/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/@node-saml/node-saml/node_modules/xml-crypto": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", - "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "node_modules/@jimp/plugin-color": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", + "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", "dependencies": { - "@xmldom/is-dom-node": "^1.0.1", - "@xmldom/xmldom": "^0.8.10", - "xpath": "^0.0.33" + "@jimp/utils": "^0.22.12", + "tinycolor2": "^1.6.0" }, - "engines": { - "node": ">=16" + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@node-saml/node-saml/node_modules/xml-crypto/node_modules/xpath": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", - "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "node_modules/@jimp/plugin-contain": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-contain/-/plugin-contain-0.22.12.tgz", + "integrity": "sha512-Eo3DmfixJw3N79lWk8q/0SDYbqmKt1xSTJ69yy8XLYQj9svoBbyRpSnHR+n9hOw5pKXytHwUW6nU4u1wegHNoQ==", "license": "MIT", - "engines": { - "node": ">=0.6.0" + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" } }, - "node_modules/@node-saml/node-saml/node_modules/xml-encryption": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz", - "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", + "node_modules/@jimp/plugin-cover": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-cover/-/plugin-cover-0.22.12.tgz", + "integrity": "sha512-z0w/1xH/v/knZkpTNx+E8a7fnasQ2wHG5ze6y5oL2dhH1UufNua8gLQXlv8/W56+4nJ1brhSd233HBJCo01BXA==", "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.8.5", - "escape-html": "^1.0.3", - "xpath": "0.0.32" + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" } }, - "node_modules/@node-saml/node-saml/node_modules/xml-encryption/node_modules/xpath": { - "version": "0.0.32", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", - "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "node_modules/@jimp/plugin-crop": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", + "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", - "engines": { - "node": ">=0.6.0" + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@node-saml/node-saml/node_modules/xpath": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", - "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "node_modules/@jimp/plugin-displace": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-displace/-/plugin-displace-0.22.12.tgz", + "integrity": "sha512-qpRM8JRicxfK6aPPqKZA6+GzBwUIitiHaZw0QrJ64Ygd3+AsTc7BXr+37k2x7QcyCvmKXY4haUrSIsBug4S3CA==", "license": "MIT", - "engines": { - "node": ">=0.6.0" + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@node-saml/passport-saml": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.1.0.tgz", - "integrity": "sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==", + "node_modules/@jimp/plugin-dither": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-dither/-/plugin-dither-0.22.12.tgz", + "integrity": "sha512-jYgGdSdSKl1UUEanX8A85v4+QUm+PE8vHFwlamaKk89s+PXQe7eVE3eNeSZX4inCq63EHL7cX580dMqkoC3ZLw==", "license": "MIT", "dependencies": { - "@node-saml/node-saml": "^5.1.0", - "@types/express": "^4.17.23", - "@types/passport": "^1.0.17", - "@types/passport-strategy": "^0.2.38", - "passport": "^0.7.0", - "passport-strategy": "^1.0.0" + "@jimp/utils": "^0.22.12" }, - "engines": { - "node": ">= 18" + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@node-saml/passport-saml/node_modules/@types/express": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", - "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "node_modules/@jimp/plugin-fisheye": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-fisheye/-/plugin-fisheye-0.22.12.tgz", + "integrity": "sha512-LGuUTsFg+fOp6KBKrmLkX4LfyCy8IIsROwoUvsUPKzutSqMJnsm3JGDW2eOmWIS/jJpPaeaishjlxvczjgII+Q==", "license": "MIT", "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@node-saml/passport-saml/node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "node_modules/@jimp/plugin-flip": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-flip/-/plugin-flip-0.22.12.tgz", + "integrity": "sha512-m251Rop7GN8W0Yo/rF9LWk6kNclngyjIJs/VXHToGQ6EGveOSTSQaX2Isi9f9lCDLxt+inBIb7nlaLLxnvHX8Q==", "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-rotate": ">=0.3.5" } }, - "node_modules/@node-saml/passport-saml/node_modules/passport": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", - "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "node_modules/@jimp/plugin-gaussian": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-gaussian/-/plugin-gaussian-0.22.12.tgz", + "integrity": "sha512-sBfbzoOmJ6FczfG2PquiK84NtVGeScw97JsCC3rpQv1PHVWyW+uqWFF53+n3c8Y0P2HWlUjflEla2h/vWShvhg==", "license": "MIT", "dependencies": { - "passport-strategy": "1.x.x", - "pause": "0.0.1", - "utils-merge": "^1.0.1" - }, - "engines": { - "node": ">= 0.4.0" + "@jimp/utils": "^0.22.12" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "node_modules/@jimp/plugin-invert": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-invert/-/plugin-invert-0.22.12.tgz", + "integrity": "sha512-N+6rwxdB+7OCR6PYijaA/iizXXodpxOGvT/smd/lxeXsZ/empHmFFFJ/FaXcYh19Tm04dGDaXcNF/dN5nm6+xQ==", "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@jimp/utils": "^0.22.12" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "node_modules/@jimp/plugin-mask": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-mask/-/plugin-mask-0.22.12.tgz", + "integrity": "sha512-4AWZg+DomtpUA099jRV8IEZUfn1wLv6+nem4NRJC7L/82vxzLCgXKTxvNvBcNmJjT9yS1LAAmiJGdWKXG63/NA==", "license": "MIT", - "engines": { - "node": ">= 8" + "dependencies": { + "@jimp/utils": "^0.22.12" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "node_modules/@jimp/plugin-normalize": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-normalize/-/plugin-normalize-0.22.12.tgz", + "integrity": "sha512-0So0rexQivnWgnhacX4cfkM2223YdExnJTTy6d06WbkfZk5alHUx8MM3yEzwoCN0ErO7oyqEWRnEkGC+As1FtA==", "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@jimp/utils": "^0.22.12" }, - "engines": { - "node": ">= 8" + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" + "node_modules/@jimp/plugin-print": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-print/-/plugin-print-0.22.12.tgz", + "integrity": "sha512-c7TnhHlxm87DJeSnwr/XOLjJU/whoiKYY7r21SbuJ5nuH+7a78EW1teOaj5gEr2wYEd7QtkFqGlmyGXY/YclyQ==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12", + "load-bmfont": "^1.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", - "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", - "license": "Apache-2.0", + "node_modules/@jimp/plugin-resize": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", + "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/utils": "^0.22.12" }, - "engines": { - "node": ">=14" + "peerDependencies": { + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", - "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "node_modules/@jimp/plugin-rotate": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", + "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", + "license": "MIT", + "dependencies": { + "@jimp/utils": "^0.22.12" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" } }, - "node_modules/@opentelemetry/core": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", - "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", - "license": "Apache-2.0", + "node_modules/@jimp/plugin-scale": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", + "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", + "license": "MIT", "dependencies": { - "@opentelemetry/semantic-conventions": "1.28.0" - }, - "engines": { - "node": ">=14" + "@jimp/utils": "^0.22.12" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" } }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.57.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", - "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", - "license": "Apache-2.0", + "node_modules/@jimp/plugin-shadow": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-shadow/-/plugin-shadow-0.22.12.tgz", + "integrity": "sha512-FX8mTJuCt7/3zXVoeD/qHlm4YH2bVqBuWQHXSuBK054e7wFRnRnbSLPUqAwSeYP3lWqpuQzJtgiiBxV3+WWwTg==", + "license": "MIT", "dependencies": { - "@opentelemetry/api-logs": "0.57.2", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" + "@jimp/utils": "^0.22.12" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blur": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" } }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.46.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.46.1.tgz", - "integrity": "sha512-AyXVnlCf/xV3K/rNumzKxZqsULyITJH6OVLiW6730JPRqWA7Zc9bvYoVNpN6iOpTU8CasH34SU/ksVJmObFibQ==", - "license": "Apache-2.0", + "node_modules/@jimp/plugin-threshold": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugin-threshold/-/plugin-threshold-0.22.12.tgz", + "integrity": "sha512-4x5GrQr1a/9L0paBC/MZZJjjgjxLYrqSmWd+e+QfAEPvmRxdRoQ5uKEuNgXnm9/weHQBTnQBQsOY2iFja+XGAw==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" + "@jimp/utils": "^0.22.12" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-color": ">=0.8.0", + "@jimp/plugin-resize": ">=0.8.0" } }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.43.0.tgz", - "integrity": "sha512-Q57JGpH6T4dkYHo9tKXONgLtxzsh1ZEW5M9A/OwKrZFyEpLqWgjhcZ3hIuVvDlhb426iDF1f9FPToV/mi5rpeA==", - "license": "Apache-2.0", + "node_modules/@jimp/plugins": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/plugins/-/plugins-0.22.12.tgz", + "integrity": "sha512-yBJ8vQrDkBbTgQZLty9k4+KtUQdRjsIDJSPjuI21YdVeqZxYywifHl4/XWILoTZsjTUASQcGoH0TuC0N7xm3ww==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.36" - }, - "engines": { - "node": ">=14" + "@jimp/plugin-blit": "^0.22.12", + "@jimp/plugin-blur": "^0.22.12", + "@jimp/plugin-circle": "^0.22.12", + "@jimp/plugin-color": "^0.22.12", + "@jimp/plugin-contain": "^0.22.12", + "@jimp/plugin-cover": "^0.22.12", + "@jimp/plugin-crop": "^0.22.12", + "@jimp/plugin-displace": "^0.22.12", + "@jimp/plugin-dither": "^0.22.12", + "@jimp/plugin-fisheye": "^0.22.12", + "@jimp/plugin-flip": "^0.22.12", + "@jimp/plugin-gaussian": "^0.22.12", + "@jimp/plugin-invert": "^0.22.12", + "@jimp/plugin-mask": "^0.22.12", + "@jimp/plugin-normalize": "^0.22.12", + "@jimp/plugin-print": "^0.22.12", + "@jimp/plugin-resize": "^0.22.12", + "@jimp/plugin-rotate": "^0.22.12", + "@jimp/plugin-scale": "^0.22.12", + "@jimp/plugin-shadow": "^0.22.12", + "@jimp/plugin-threshold": "^0.22.12", + "timm": "^1.6.1" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.16.0.tgz", - "integrity": "sha512-88+qCHZC02up8PwKHk0UQKLLqGGURzS3hFQBZC7PnGwReuoKjHXS1o29H58S+QkXJpkTr2GACbx8j6mUoGjNPA==", - "license": "Apache-2.0", + "node_modules/@jimp/png": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/png/-/png-0.22.12.tgz", + "integrity": "sha512-Mrp6dr3UTn+aLK8ty/dSKELz+Otdz1v4aAXzV5q53UDD2rbB5joKVJ/ChY310B+eRzNxIovbUF1KVrUsYdE8Hg==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" - }, - "engines": { - "node": ">=14" + "@jimp/utils": "^0.22.12", + "pngjs": "^6.0.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@opentelemetry/instrumentation-express": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.47.0.tgz", - "integrity": "sha512-XFWVx6k0XlU8lu6cBlCa29ONtVt6ADEjmxtyAyeF2+rifk8uBJbk1La0yIVfI0DoKURGbaEDTNelaXG9l/lNNQ==", - "license": "Apache-2.0", + "node_modules/@jimp/tiff": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/tiff/-/tiff-0.22.12.tgz", + "integrity": "sha512-E1LtMh4RyJsoCAfAkBRVSYyZDTtLq9p9LUiiYP0vPtXyxX4BiYBUYihTLSBlCQg5nF2e4OpQg7SPrLdJ66u7jg==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" + "utif2": "^4.0.1" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@opentelemetry/instrumentation-fastify": { - "version": "0.44.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fastify/-/instrumentation-fastify-0.44.1.tgz", - "integrity": "sha512-RoVeMGKcNttNfXMSl6W4fsYoCAYP1vi6ZAWIGhBY+o7R9Y0afA7f9JJL0j8LHbyb0P0QhSYk+6O56OwI2k4iRQ==", - "license": "Apache-2.0", + "node_modules/@jimp/types": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/types/-/types-0.22.12.tgz", + "integrity": "sha512-wwKYzRdElE1MBXFREvCto5s699izFHNVvALUv79GXNbsOVqlwlOxlWJ8DuyOGIXoLP4JW/m30YyuTtfUJgMRMA==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" + "@jimp/bmp": "^0.22.12", + "@jimp/gif": "^0.22.12", + "@jimp/jpeg": "^0.22.12", + "@jimp/png": "^0.22.12", + "@jimp/tiff": "^0.22.12", + "timm": "^1.6.1" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jimp/custom": ">=0.3.5" } }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.19.0.tgz", - "integrity": "sha512-JGwmHhBkRT2G/BYNV1aGI+bBjJu4fJUD/5/Jat0EWZa2ftrLV3YE8z84Fiij/wK32oMZ88eS8DI4ecLGZhpqsQ==", - "license": "Apache-2.0", + "node_modules/@jimp/utils": { + "version": "0.22.12", + "resolved": "https://registry.npmjs.org/@jimp/utils/-/utils-0.22.12.tgz", + "integrity": "sha512-yJ5cWUknGnilBq97ZXOyOS0HhsHOyAyjHwYfHxGbSyMTohgQI6sVyE8KPgDwH8HHW/nMKXk8TrSwAE71zt716Q==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "regenerator-runtime": "^0.13.3" } }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.43.0.tgz", - "integrity": "sha512-at8GceTtNxD1NfFKGAuwtqM41ot/TpcLh+YsGe4dhf7gvv1HW/ZWdq6nfRtS6UjIvZJOokViqLPJ3GVtZItAnQ==", - "license": "Apache-2.0", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" - }, + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=6.0.0" } }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.47.0.tgz", - "integrity": "sha512-Cc8SMf+nLqp0fi8oAnooNEfwZWFnzMiBHCGmDFYqmgjPylyLmi83b+NiTns/rKGwlErpW0AGPt0sMpkbNlzn8w==", - "license": "Apache-2.0", + "node_modules/@jridgewell/source-map": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", + "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" } }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.45.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.45.1.tgz", - "integrity": "sha512-VH6mU3YqAKTePPfUPwfq4/xr049774qWtfTuJqVHoVspCLiT3bW+fCQ1toZxt6cxRPYASoYaBsMA3CWo8B8rcw==", - "license": "Apache-2.0", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.57.1.tgz", - "integrity": "sha512-ThLmzAQDs7b/tdKI3BV2+yawuF09jF111OFsovqT1Qj3D8vjwKBwhi/rDE5xethwn4tSXtZcJ9hBsVAlWFQZ7g==", + "node_modules/@julusian/jpeg-turbo": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@julusian/jpeg-turbo/-/jpeg-turbo-2.1.0.tgz", + "integrity": "sha512-tmMnOTuiXr3W+lPOlyrG/efBxVvp/tbJe6iVsY2qKl5TLHytCkWabsnq4PHS6ddCYJeiGWQBvRwQpKlFd3I2gg==", + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/instrumentation": "0.57.1", - "@opentelemetry/semantic-conventions": "1.28.0", - "forwarded-parse": "2.1.2", - "semver": "^7.5.2" + "cmake-js": "^7.0.0", + "node-addon-api": "^5.0.0", + "pkg-prebuilds": "~0.1.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=14.15" } }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/api-logs": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.1.tgz", - "integrity": "sha512-I4PHczeujhQAQv6ZBzqHYEUiggZL4IdSMixtVD3EYqbdrjujE7kRfI5QohjlPoJm8BvenoW5YaTMWRrbpot6tg==", - "license": "Apache-2.0", + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz", + "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==", + "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=14" + "sparse-bitfield": "^3.0.3" } }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/instrumentation": { - "version": "0.57.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.1.tgz", - "integrity": "sha512-SgHEKXoVxOjc20ZYusPG3Fh+RLIZTSa4x8QtD3NfgAUDyqdFFS9W1F2ZVbZkqDCdyMcQG02Ok4duUGLHJXHgbA==", - "license": "Apache-2.0", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@opentelemetry/api-logs": "0.57.1", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" + "@tybys/wasm-util": "^0.10.3" }, - "engines": { - "node": ">=14" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", + "node_modules/@node-saml/node-saml": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz", + "integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.12", + "@types/qs": "^6.9.18", + "@types/xml-encryption": "^1.2.4", + "@types/xml2js": "^0.4.14", + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "debug": "^4.4.0", + "xml-crypto": "^6.1.2", + "xml-encryption": "^3.1.0", + "xml2js": "^0.6.2", + "xmlbuilder": "^15.1.1", + "xpath": "^0.0.34" + }, "engines": { - "node": ">=14" + "node": ">= 18" } }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.47.0.tgz", - "integrity": "sha512-4HqP9IBC8e7pW9p90P3q4ox0XlbLGme65YTrA3UTLvqvo4Z6b0puqZQP203YFu8m9rE/luLfaG7/xrwwqMUpJw==", - "license": "Apache-2.0", + "node_modules/@node-saml/node-saml/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.27.0" + "ms": "^2.1.3" }, "engines": { - "node": ">=14" + "node": ">=6.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.7.0.tgz", - "integrity": "sha512-LB+3xiNzc034zHfCtgs4ITWhq6Xvdo8bsq7amR058jZlf2aXXDrN9SV4si4z2ya9QX4tz6r4eZJwDkXOp14/AQ==", - "license": "Apache-2.0", + "node_modules/@node-saml/node-saml/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/@node-saml/node-saml/node_modules/xml-crypto": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" + "@xmldom/is-dom-node": "^1.0.1", + "@xmldom/xmldom": "^0.8.10", + "xpath": "^0.0.33" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=16" } }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.44.0.tgz", - "integrity": "sha512-SlT0+bLA0Lg3VthGje+bSZatlGHw/vwgQywx0R/5u9QC59FddTQSPJeWNw29M6f8ScORMeUOOTwihlQAn4GkJQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, + "node_modules/@node-saml/node-saml/node_modules/xml-crypto/node_modules/xpath": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz", + "integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==", + "license": "MIT", "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=0.6.0" } }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.47.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.47.0.tgz", - "integrity": "sha512-HFdvqf2+w8sWOuwtEXayGzdZ2vWpCKEQv5F7+2DSA74Te/Cv4rvb2E5So5/lh+ok4/RAIPuvCbCb/SHQFzMmbw==", - "license": "Apache-2.0", + "node_modules/@node-saml/node-saml/node_modules/xml-encryption": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz", + "integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, + "@xmldom/xmldom": "^0.8.5", + "escape-html": "^1.0.3", + "xpath": "0.0.32" + } + }, + "node_modules/@node-saml/node-saml/node_modules/xml-encryption/node_modules/xpath": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz", + "integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==", + "license": "MIT", "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=0.6.0" } }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.44.0.tgz", - "integrity": "sha512-Tn7emHAlvYDFik3vGU0mdwvWJDwtITtkJ+5eT2cUquct6nIs+H8M47sqMJkCpyPe5QIBJoTOHxmc6mj9lz6zDw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0" - }, + "node_modules/@node-saml/node-saml/node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=0.6.0" } }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.51.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.51.0.tgz", - "integrity": "sha512-cMKASxCX4aFxesoj3WK8uoQ0YUrRvnfxaO72QWI2xLu5ZtgX/QvdGBlU3Ehdond5eb74c2s1cqRQUIptBnKz1g==", - "license": "Apache-2.0", + "node_modules/@node-saml/passport-saml": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@node-saml/passport-saml/-/passport-saml-5.1.0.tgz", + "integrity": "sha512-pBm+iFjv9eihcgeJuSUs4c0AuX1QEFdHwP8w1iaWCfDzXdeWZxUBU5HT2bY2S4dvNutcy+A9hYsH7ZLBGtgwDg==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" + "@node-saml/node-saml": "^5.1.0", + "@types/express": "^4.17.23", + "@types/passport": "^1.0.17", + "@types/passport-strategy": "^0.2.38", + "passport": "^0.7.0", + "passport-strategy": "^1.0.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">= 18" } }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.46.0.tgz", - "integrity": "sha512-mtVv6UeaaSaWTeZtLo4cx4P5/ING2obSqfWGItIFSunQBrYROfhuVe7wdIrFUs2RH1tn2YYpAJyMaRe/bnTTIQ==", - "license": "Apache-2.0", + "node_modules/@node-saml/passport-saml/node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" } }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.45.0.tgz", - "integrity": "sha512-tWWyymgwYcTwZ4t8/rLDfPYbOTF3oYB8SxnYMtIQ1zEf5uDm90Ku3i6U/vhaMyfHNlIHvDhvJh+qx5Nc4Z3Acg==", - "license": "Apache-2.0", + "node_modules/@node-saml/passport-saml/node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/mysql": "2.15.26" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.45.0.tgz", - "integrity": "sha512-qLslv/EPuLj0IXFvcE3b0EqhWI8LKmrgRPIa4gUd8DllbBpqJAvLNJSv3cC6vWwovpbSI3bagNO/3Q2SuXv2xA==", - "license": "Apache-2.0", + "node_modules/@node-saml/passport-saml/node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@opentelemetry/sql-common": "^0.40.1" + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" }, "engines": { - "node": ">=14" + "node": ">= 0.4.0" }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/@opentelemetry/instrumentation-nestjs-core": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-nestjs-core/-/instrumentation-nestjs-core-0.44.0.tgz", - "integrity": "sha512-t16pQ7A4WYu1yyQJZhRKIfUNvl5PAaF2pEteLvgJb/BWdd1oNuU1rOYt4S825kMy+0q4ngiX281Ss9qiwHfxFQ==", - "license": "Apache-2.0", + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0" + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">= 8" } }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.50.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.50.0.tgz", - "integrity": "sha512-TtLxDdYZmBhFswm8UIsrDjh/HFBeDXd4BLmE8h2MxirNHewLJ0VS9UUddKKEverb5Sm2qFVjqRjcU+8Iw4FJ3w==", - "license": "Apache-2.0", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", "dependencies": { - "@opentelemetry/core": "^1.26.0", - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "1.27.0", - "@opentelemetry/sql-common": "^0.40.1", - "@types/pg": "8.6.1", - "@types/pg-pool": "2.0.6" + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">= 8" } }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.27.0.tgz", - "integrity": "sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==", + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", "engines": { - "node": ">=14" + "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/instrumentation-redis-4": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis-4/-/instrumentation-redis-4-0.46.0.tgz", - "integrity": "sha512-aTUWbzbFMFeRODn3720TZO0tsh/49T8H3h8vVnVKJ+yE36AeW38Uj/8zykQ/9nO8Vrtjr5yKuX3uMiG/W8FKNw==", + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/redis-common": "^0.36.2", - "@opentelemetry/semantic-conventions": "^1.27.0" + "@opentelemetry/api": "^1.3.0" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.18.0.tgz", - "integrity": "sha512-9zhjDpUDOtD+coeADnYEJQ0IeLVCj7w/hqzIutdp5NqS1VqTAanaEfsEcSypyvYv5DX3YOsTUoF+nr2wDXPETA==", + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/instrumentation": "^0.57.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/tedious": "^4.0.14" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/instrumentation-undici": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.10.0.tgz", - "integrity": "sha512-vm+V255NGw9gaSsPD6CP0oGo8L55BffBc8KnxqsMuc6XiAD1L8SFNzsW0RHhxJFqy9CJaJh+YiJ5EHXuZ5rZBw==", + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "^1.8.0", - "@opentelemetry/instrumentation": "^0.57.0" + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.7.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.36.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.36.2.tgz", - "integrity": "sha512-faYX1N0gpLhej/6nyp6bgRjzAKXn5GOEMYY7YhciSfCoITAktLUtQ36d24QEWNA1/WA1y6qQunCe0OhHRkVl9g==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@opentelemetry/api": "^1.3.0" } }, "node_modules/@opentelemetry/resources": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", - "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", - "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/resources": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.36.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz", - "integrity": "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==", + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", "license": "Apache-2.0", "engines": { "node": ">=14" } }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.40.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.40.1.tgz", - "integrity": "sha512-nSDlnHSqzC3pXn/wZEZVLuAuJ1MYMXPBwtv2qAbCa3847SaHItdE7SzUq/Jtb0KZmh1zfAbNi3AAMjztTT4Ugg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^1.1.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@pkgjs/parseargs": { @@ -4586,68 +4707,25 @@ "node": ">=6" } }, - "node_modules/@postman/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/@postman/tunnel-agent": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.4.tgz", - "integrity": "sha512-CJJlq8V7rNKhAw4sBfjixKpJW00SHqebqNUQKxMoepgeWZIbdPcD+rguRcivGhS4N12PymDcKgUgSD4rVC+RjQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@prisma/instrumentation": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-5.22.0.tgz", - "integrity": "sha512-LxccF392NN37ISGxIurUljZSh1YWnphO34V5a0+T7FVQG2u9bhAXRTJpgmQ3483woVhkraQZFF7cbRrpbw/F4Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.8", - "@opentelemetry/instrumentation": "^0.49 || ^0.50 || ^0.51 || ^0.52.0 || ^0.53.0", - "@opentelemetry/sdk-trace-base": "^1.22" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.53.0.tgz", - "integrity": "sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, + "node_modules/@postman/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">= 4.0.0" } }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.53.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.53.0.tgz", - "integrity": "sha512-DMwg0hy4wzf7K73JJtl95m/e0boSoWhH07rfvHvYzQtBD3Bmv0Wc1x733vyZBqmFm8OjJD0/pfiUg1W3JjFX0A==", + "node_modules/@postman/tunnel-agent": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.4.tgz", + "integrity": "sha512-CJJlq8V7rNKhAw4sBfjixKpJW00SHqebqNUQKxMoepgeWZIbdPcD+rguRcivGhS4N12PymDcKgUgSD4rVC+RjQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.53.0", - "@types/shimmer": "^1.2.0", - "import-in-the-middle": "^1.8.1", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.2", - "shimmer": "^1.2.1" + "safe-buffer": "^5.0.1" }, "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": "*" } }, "node_modules/@protobuf-ts/plugin": { @@ -4769,85 +4847,386 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, + "node_modules/@sentry/conventions": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz", + "integrity": "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@sentry/core": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.0.tgz", - "integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==", + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", "license": "MIT", "engines": { - "node": ">=14.18" + "node": ">=18" } }, "node_modules/@sentry/node": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-8.55.0.tgz", - "integrity": "sha512-h10LJLDTRAzYgay60Oy7moMookqqSZSviCWkkmHZyaDn+4WURnPp5SKhhfrzPRQcXKrweiOwDSHBgn1tweDssg==", + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.62.0.tgz", + "integrity": "sha512-4hoU67bJY0o3irEDMZu2UIztAOsvEqFkLXA7EUKl1LXMA3Ba1Lb32OUVqlsTypiEInSDs/BtM+aAFKojZ3P3Fw==", "license": "MIT", "dependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/instrumentation-amqplib": "^0.46.0", - "@opentelemetry/instrumentation-connect": "0.43.0", - "@opentelemetry/instrumentation-dataloader": "0.16.0", - "@opentelemetry/instrumentation-express": "0.47.0", - "@opentelemetry/instrumentation-fastify": "0.44.1", - "@opentelemetry/instrumentation-fs": "0.19.0", - "@opentelemetry/instrumentation-generic-pool": "0.43.0", - "@opentelemetry/instrumentation-graphql": "0.47.0", - "@opentelemetry/instrumentation-hapi": "0.45.1", - "@opentelemetry/instrumentation-http": "0.57.1", - "@opentelemetry/instrumentation-ioredis": "0.47.0", - "@opentelemetry/instrumentation-kafkajs": "0.7.0", - "@opentelemetry/instrumentation-knex": "0.44.0", - "@opentelemetry/instrumentation-koa": "0.47.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.44.0", - "@opentelemetry/instrumentation-mongodb": "0.51.0", - "@opentelemetry/instrumentation-mongoose": "0.46.0", - "@opentelemetry/instrumentation-mysql": "0.45.0", - "@opentelemetry/instrumentation-mysql2": "0.45.0", - "@opentelemetry/instrumentation-nestjs-core": "0.44.0", - "@opentelemetry/instrumentation-pg": "0.50.0", - "@opentelemetry/instrumentation-redis-4": "0.46.0", - "@opentelemetry/instrumentation-tedious": "0.18.0", - "@opentelemetry/instrumentation-undici": "0.10.0", - "@opentelemetry/resources": "^1.30.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", - "@opentelemetry/semantic-conventions": "^1.28.0", - "@prisma/instrumentation": "5.22.0", - "@sentry/core": "8.55.0", - "@sentry/opentelemetry": "8.55.0", - "import-in-the-middle": "^1.11.2" + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/core": "10.62.0", + "@sentry/node-core": "10.62.0", + "@sentry/opentelemetry": "10.62.0", + "@sentry/server-utils": "10.62.0", + "import-in-the-middle": "^3.0.0" }, "engines": { - "node": ">=14.18" + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.62.0.tgz", + "integrity": "sha512-V7rDgbxViiHU0OpcFEDp3l41IFvWTasKHfXw8SQ6yIgtZ8VpFqmz2TR5N7X85iIOmWIvK5HV0yp0eDdsly0+rA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0", + "@sentry/opentelemetry": "10.62.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } } }, "node_modules/@sentry/opentelemetry": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-8.55.0.tgz", - "integrity": "sha512-UvatdmSr3Xf+4PLBzJNLZ2JjG1yAPWGe/VrJlJAqyTJ2gKeTzgXJJw8rp4pbvNZO8NaTGEYhhO+scLUj0UtLAQ==", + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.62.0.tgz", + "integrity": "sha512-nFwBgtjfwgY8P5lAuQFWfAsQW1MXxuQ6kR/HtBs+A6julqwGGS2QnQ65OCWMzz6IqDEL/pRgT1405/gU+OXU3A==", "license": "MIT", "dependencies": { - "@sentry/core": "8.55.0" + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0" }, "engines": { - "node": ">=14.18" + "node": ">=18" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", - "@opentelemetry/context-async-hooks": "^1.30.1", - "@opentelemetry/core": "^1.30.1", - "@opentelemetry/instrumentation": "^0.57.1", - "@opentelemetry/sdk-trace-base": "^1.30.1", - "@opentelemetry/semantic-conventions": "^1.28.0" + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.62.0.tgz", + "integrity": "sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/tracing-hooks": "^0.10.0", + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0", + "magic-string": "~0.30.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@serialport/binding-mock": { @@ -5067,161 +5446,438 @@ "engines": { "node": ">=20.0.0" }, - "funding": { - "url": "https://opencollective.com/serialport/donate" + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", + "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", + "license": "MIT", + "dependencies": { + "@serialport/bindings-interface": "1.2.2", + "debug": "4.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/serialport/donate" + } + }, + "node_modules/@serialport/stream/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@serialport/stream/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/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", + "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", + "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.2.tgz", + "integrity": "sha512-JoLw59sT5Bm8SAjFCYZyuCGxK8y3vovmoVbZWLDPTH5XpPEIwpFd9m90jjVMwoypDuB/SdVgje5Y4T7w50lJaw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.4.tgz", + "integrity": "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.4.tgz", + "integrity": "sha512-3fb/9SYaYqbpy/z/H3yIi0bYKyAa89y6xPmIqwr2vQiUT2St+avRt8UKwsWt9fEdEasc5d/V+QjrviRaX1JRFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.2.tgz", + "integrity": "sha512-JGtambizrWP50xHgbzZI04IWU7LdI0nh/wGbqH3sJesYToMi2j/DcoElqyOcqEIG/D4tNyxgRuaqBXWE3zOFhQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.4.tgz", + "integrity": "sha512-RD6UwNZ5zISpOWPuhVgRz60GkSIp0dy1fuZmj4RYmqLVRtejFqQ16WmfYDdoSoAjlp1LX+FnZo+/hkdmyyGZ1w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.4.tgz", + "integrity": "sha512-UeJpOmLGhq1SLox79QWw/0n2PFX+oPRE1ZyRMxPIaFEfCqWaqpB7BU9C8kpPOGEhLF7AwEqfFbtwNxGy4ReENA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", + "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.4.tgz", + "integrity": "sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.0.0", + "@smithy/chunked-blob-reader-native": "^4.0.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.4.tgz", + "integrity": "sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@serialport/stream": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@serialport/stream/-/stream-13.0.0.tgz", - "integrity": "sha512-F7xLJKsjGo2WuEWMSEO1SimRcOA+WtWICsY13r0ahx8s2SecPQH06338g28OT7cW7uRXI7oEQAk62qh5gHJW3g==", - "license": "MIT", + "node_modules/@smithy/invalid-dependency": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", + "license": "Apache-2.0", "dependencies": { - "@serialport/bindings-interface": "1.2.2", - "debug": "4.4.0" + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" }, "engines": { - "node": ">=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/serialport/donate" + "node": ">=18.0.0" } }, - "node_modules/@serialport/stream/node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", - "license": "MIT", + "node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "license": "Apache-2.0", "dependencies": { - "ms": "^2.1.3" + "tslib": "^2.6.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=18.0.0" } }, - "node_modules/@serialport/stream/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/@smithy/abort-controller": { + "node_modules/@smithy/md5-js": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", - "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.4.tgz", + "integrity": "sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/chunked-blob-reader": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", - "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", + "node_modules/@smithy/middleware-content-length": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", "license": "Apache-2.0", "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", - "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", + "node_modules/@smithy/middleware-endpoint": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.17.tgz", + "integrity": "sha512-S3hSGLKmHG1m35p/MObQCBCdRsrpbPU8B129BVzRqRfDvQqPMQ14iO4LyRw+7LNizYc605COYAcjqgawqi+6jA==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-base64": "^4.0.0", + "@smithy/core": "^3.7.2", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/config-resolver": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", - "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", + "node_modules/@smithy/middleware-retry": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.18.tgz", + "integrity": "sha512-bYLZ4DkoxSsPxpdmeapvAKy7rM5+25gR7PGxq2iMiecmbrRGBHj9s75N74Ylg+aBiw9i5jIowC/cLU2NR0qH8w==", "license": "Apache-2.0", "dependencies": { "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.6", + "@smithy/smithy-client": "^4.4.9", "@smithy/types": "^4.3.1", - "@smithy/util-config-provider": "^4.0.0", "@smithy/util-middleware": "^4.0.4", - "tslib": "^2.6.2" + "@smithy/util-retry": "^4.0.6", + "tslib": "^2.6.2", + "uuid": "^9.0.1" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/core": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.2.tgz", - "integrity": "sha512-JoLw59sT5Bm8SAjFCYZyuCGxK8y3vovmoVbZWLDPTH5XpPEIwpFd9m90jjVMwoypDuB/SdVgje5Y4T7w50lJaw==", + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.0.8", "@smithy/protocol-http": "^5.1.2", "@smithy/types": "^4.3.1", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-middleware": "^4.0.4", - "@smithy/util-stream": "^4.2.3", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/credential-provider-imds": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", - "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", + "node_modules/@smithy/middleware-stack": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.3", "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", "@smithy/types": "^4.3.1", - "@smithy/url-parser": "^4.0.4", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-codec": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.4.tgz", - "integrity": "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig==", + "node_modules/@smithy/node-http-handler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", + "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", "@smithy/types": "^4.3.1", - "@smithy/util-hex-encoding": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-browser": { + "node_modules/@smithy/property-provider": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.4.tgz", - "integrity": "sha512-3fb/9SYaYqbpy/z/H3yIi0bYKyAa89y6xPmIqwr2vQiUT2St+avRt8UKwsWt9fEdEasc5d/V+QjrviRaX1JRFA==", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5229,10 +5885,10 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.2.tgz", - "integrity": "sha512-JGtambizrWP50xHgbzZI04IWU7LdI0nh/wGbqH3sJesYToMi2j/DcoElqyOcqEIG/D4tNyxgRuaqBXWE3zOFhQ==", + "node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.3.1", @@ -5242,27 +5898,26 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-node": { + "node_modules/@smithy/querystring-builder": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.4.tgz", - "integrity": "sha512-RD6UwNZ5zISpOWPuhVgRz60GkSIp0dy1fuZmj4RYmqLVRtejFqQ16WmfYDdoSoAjlp1LX+FnZo+/hkdmyyGZ1w==", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.0.4", "@smithy/types": "^4.3.1", + "@smithy/util-uri-escape": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/eventstream-serde-universal": { + "node_modules/@smithy/querystring-parser": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.4.tgz", - "integrity": "sha512-UeJpOmLGhq1SLox79QWw/0n2PFX+oPRE1ZyRMxPIaFEfCqWaqpB7BU9C8kpPOGEhLF7AwEqfFbtwNxGy4ReENA==", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5270,30 +5925,24 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/fetch-http-handler": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", - "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", + "node_modules/@smithy/service-error-classification": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", + "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.2", - "@smithy/querystring-builder": "^4.0.4", - "@smithy/types": "^4.3.1", - "@smithy/util-base64": "^4.0.0", - "tslib": "^2.6.2" + "@smithy/types": "^4.3.1" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-blob-browser": { + "node_modules/@smithy/shared-ini-file-loader": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.4.tgz", - "integrity": "sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", "license": "Apache-2.0", "dependencies": { - "@smithy/chunked-blob-reader": "^5.0.0", - "@smithy/chunked-blob-reader-native": "^4.0.0", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5301,14 +5950,18 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-node": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", - "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", + "node_modules/@smithy/signature-v4": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", "@smithy/types": "^4.3.1", - "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-uri-escape": "^4.0.0", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, @@ -5316,26 +5969,43 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/hash-stream-node": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.4.tgz", - "integrity": "sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==", + "node_modules/@smithy/smithy-client": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.9.tgz", + "integrity": "sha512-mbMg8mIUAWwMmb74LoYiArP04zWElPzDoA1jVOp3or0cjlDMgoS6WTC3QXK0Vxoc9I4zdrX0tq6qsOmaIoTWEQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/core": "^3.7.2", + "@smithy/middleware-endpoint": "^4.1.17", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", "@smithy/types": "^4.3.1", - "@smithy/util-utf8": "^4.0.0", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "license": "Apache-2.0", + "dependencies": { "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/invalid-dependency": { + "node_modules/@smithy/url-parser": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", - "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/querystring-parser": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5343,118 +6013,96 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/is-array-buffer": { + "node_modules/@smithy/util-base64": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", - "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", "license": "Apache-2.0", "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/md5-js": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.4.tgz", - "integrity": "sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==", + "node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.1", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-content-length": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", - "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", + "node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.2", - "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-endpoint": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.17.tgz", - "integrity": "sha512-S3hSGLKmHG1m35p/MObQCBCdRsrpbPU8B129BVzRqRfDvQqPMQ14iO4LyRw+7LNizYc605COYAcjqgawqi+6jA==", + "node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.7.2", - "@smithy/middleware-serde": "^4.0.8", - "@smithy/node-config-provider": "^4.1.3", - "@smithy/shared-ini-file-loader": "^4.0.4", - "@smithy/types": "^4.3.1", - "@smithy/url-parser": "^4.0.4", - "@smithy/util-middleware": "^4.0.4", + "@smithy/is-array-buffer": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-retry": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.18.tgz", - "integrity": "sha512-bYLZ4DkoxSsPxpdmeapvAKy7rM5+25gR7PGxq2iMiecmbrRGBHj9s75N74Ylg+aBiw9i5jIowC/cLU2NR0qH8w==", + "node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.1.3", - "@smithy/protocol-http": "^5.1.2", - "@smithy/service-error-classification": "^4.0.6", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "@smithy/util-middleware": "^4.0.4", - "@smithy/util-retry": "^4.0.6", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-retry/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@smithy/middleware-serde": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", - "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.25.tgz", + "integrity": "sha512-pxEWsxIsOPLfKNXvpgFHBGFC3pKYKUFhrud1kyooO9CJai6aaKDHfT10Mi5iiipPXN/JhKAu3qX9o75+X85OdQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.1.2", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/middleware-stack": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", - "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.25", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.25.tgz", + "integrity": "sha512-+w4n4hKFayeCyELZLfsSQG5mCC3TwSkmRHv4+el5CzFU8ToQpYGhpV7mrRzqlwKkntlPilT1HJy1TVeEvEjWOQ==", "license": "Apache-2.0", "dependencies": { + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.9", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5462,14 +6110,13 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/node-config-provider": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", - "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", + "node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.0.4", - "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5477,26 +6124,22 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/node-http-handler": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", - "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", + "node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.4", - "@smithy/protocol-http": "^5.1.2", - "@smithy/querystring-builder": "^4.0.4", - "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/property-provider": { + "node_modules/@smithy/util-middleware": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", - "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.3.1", @@ -5506,12 +6149,13 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/protocol-http": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", - "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "node_modules/@smithy/util-retry": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", + "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", "license": "Apache-2.0", "dependencies": { + "@smithy/service-error-classification": "^4.0.6", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5519,51 +6163,57 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", - "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", + "node_modules/@smithy/util-stream": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", + "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", "license": "Apache-2.0", "dependencies": { + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", "@smithy/types": "^4.3.1", - "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-parser": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", - "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", + "node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/service-error-classification": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", - "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", + "node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.3.1" + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", - "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "node_modules/@smithy/util-waiter": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.6.tgz", + "integrity": "sha512-slcr1wdRbX7NFphXZOxtxRNA7hXAAtJAXJDE/wdoMAos27SIquVCKiSqfB6/28YzQ8FCsB5NKkhdM5gMADbqxg==", "license": "Apache-2.0", "dependencies": { + "@smithy/abort-controller": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -5571,1517 +6221,1726 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/signature-v4": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", - "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", - "license": "Apache-2.0", + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node20": { + "version": "20.1.8", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.8.tgz", + "integrity": "sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bluebird": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.42.tgz", + "integrity": "sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/chalk": { + "version": "0.4.31", + "resolved": "https://registry.npmjs.org/@types/chalk/-/chalk-0.4.31.tgz", + "integrity": "sha512-nF0fisEPYMIyfrFgabFimsz9Lnuu9MwkNrrlATm2E4E46afKDyeelT+8bXfw1VSc7sLBxMxRgT7PxTC2JcqN4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chrome-remote-interface": { + "version": "0.31.14", + "resolved": "https://registry.npmjs.org/@types/chrome-remote-interface/-/chrome-remote-interface-0.31.14.tgz", + "integrity": "sha512-H9hTcLu1y+Ms6GDPXXeGhgxaOSD69yEo674vjJw5EeW1tTwYo8fEkf7A9nWlnO6ArJsS7c41iZeX6mRDQ1LhEw==", + "license": "MIT", + "dependencies": { + "devtools-protocol": "0.0.927104" + } + }, + "node_modules/@types/connect": { + "version": "3.4.36", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", + "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "license": "MIT", "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "@smithy/protocol-http": "^5.1.2", - "@smithy/types": "^4.3.1", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-middleware": "^4.0.4", - "@smithy/util-uri-escape": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@smithy/smithy-client": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.9.tgz", - "integrity": "sha512-mbMg8mIUAWwMmb74LoYiArP04zWElPzDoA1jVOp3or0cjlDMgoS6WTC3QXK0Vxoc9I4zdrX0tq6qsOmaIoTWEQ==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/core": "^3.7.2", - "@smithy/middleware-endpoint": "^4.1.17", - "@smithy/middleware-stack": "^4.0.4", - "@smithy/protocol-http": "^5.1.2", - "@smithy/types": "^4.3.1", - "@smithy/util-stream": "^4.2.3", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", + "license": "MIT" + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "license": "MIT", + "peerDependencies": { + "@types/express": "*" } }, - "node_modules/@smithy/types": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", - "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", - "license": "Apache-2.0", + "node_modules/@types/cookie-session": { + "version": "2.0.49", + "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.49.tgz", + "integrity": "sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/express": "*", + "@types/keygrip": "*" } }, - "node_modules/@smithy/url-parser": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", - "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", - "license": "Apache-2.0", + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", "dependencies": { - "@smithy/querystring-parser": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@smithy/util-base64": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", - "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", - "license": "Apache-2.0", + "node_modules/@types/csurf": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.5.tgz", + "integrity": "sha512-5rw87+5YGixyL2W8wblSUl5DSZi5YOlXE6Awwn2ofLvqKr/1LruKffrQipeJKUX44VaxKj8m5es3vfhltJTOoA==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/express-serve-static-core": "*" } }, - "node_modules/@smithy/util-body-length-browser": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", - "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", - "license": "Apache-2.0", + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/ms": "*" } }, - "node_modules/@smithy/util-body-length-node": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", - "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", - "license": "Apache-2.0", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/estree": "*", + "@types/json-schema": "*" } }, - "node_modules/@smithy/util-buffer-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", - "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", - "license": "Apache-2.0", + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", "dependencies": { - "@smithy/is-array-buffer": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/eslint": "*", + "@types/estree": "*" } }, - "node_modules/@smithy/util-config-provider": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", - "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", - "license": "Apache-2.0", + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/eventemitter3": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/eventemitter3/-/eventemitter3-1.2.0.tgz", + "integrity": "sha512-qB+RJcqIM5b8/CfmjqiBylK2KxBGKpRlewjsfq+IVFrDhclWO9JodK4g3NbY0Kpf/3CcLbz+tHMH6bxJEw0Z3A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" } }, - "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.0.25", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.25.tgz", - "integrity": "sha512-pxEWsxIsOPLfKNXvpgFHBGFC3pKYKUFhrud1kyooO9CJai6aaKDHfT10Mi5iiipPXN/JhKAu3qX9o75+X85OdQ==", - "license": "Apache-2.0", + "node_modules/@types/express-serve-static-core": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", + "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "license": "MIT", "dependencies": { - "@smithy/property-provider": "^4.0.4", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" } }, - "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.0.25", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.25.tgz", - "integrity": "sha512-+w4n4hKFayeCyELZLfsSQG5mCC3TwSkmRHv4+el5CzFU8ToQpYGhpV7mrRzqlwKkntlPilT1HJy1TVeEvEjWOQ==", - "license": "Apache-2.0", + "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==", + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.16", + "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", + "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/config-resolver": "^4.1.4", - "@smithy/credential-provider-imds": "^4.0.6", - "@smithy/node-config-provider": "^4.1.3", - "@smithy/property-provider": "^4.0.4", - "@smithy/smithy-client": "^4.4.9", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@smithy/util-endpoints": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", - "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", - "license": "Apache-2.0", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/keygrip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", + "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.17.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", + "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", + "license": "MIT", "dependencies": { - "@smithy/node-config-provider": "^4.1.3", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "undici-types": "~6.21.0" } }, - "node_modules/@smithy/util-hex-encoding": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", - "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", - "license": "Apache-2.0", + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/express": "*" } }, - "node_modules/@smithy/util-middleware": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", - "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", - "license": "Apache-2.0", + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "license": "MIT", "dependencies": { - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/express": "*", + "@types/passport": "*" } }, - "node_modules/@smithy/util-retry": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", - "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", - "license": "Apache-2.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==", + "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==", + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", "dependencies": { - "@smithy/service-error-classification": "^4.0.6", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/mime": "^1", + "@types/node": "*" } }, - "node_modules/@smithy/util-stream": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", - "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", - "license": "Apache-2.0", + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "license": "MIT", "dependencies": { - "@smithy/fetch-http-handler": "^5.1.0", - "@smithy/node-http-handler": "^4.1.0", - "@smithy/types": "^4.3.1", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-buffer-from": "^4.0.0", - "@smithy/util-hex-encoding": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", - "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", - "license": "Apache-2.0", + "node_modules/@types/split": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/split/-/split-1.0.5.tgz", + "integrity": "sha512-gMiDr4vA6YofTpAkPQtP+5pvStIf3CMYphf32YAG/3RwogNL8ii1CQKDc+sxN62KuxPoRaJXcf2zDCDkEBH4FA==", + "dev": true, + "license": "MIT", "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*", + "@types/through": "*" } }, - "node_modules/@smithy/util-utf8": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", - "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", - "license": "Apache-2.0", + "node_modules/@types/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/@types/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-+VfWIwrlept2VBTj7Y2wQnI/Xfscy1u8Pyj/puYwss6V1IblXn1x7S0S9eFh6KyBolgLCm+rUFzhFAbdkR691g==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/util-buffer-from": "^4.0.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@smithy/util-waiter": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.6.tgz", - "integrity": "sha512-slcr1wdRbX7NFphXZOxtxRNA7hXAAtJAXJDE/wdoMAos27SIquVCKiSqfB6/28YzQ8FCsB5NKkhdM5gMADbqxg==", - "license": "Apache-2.0", + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@smithy/abort-controller": "^4.0.4", - "@smithy/types": "^4.3.1", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "*" } }, - "node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", - "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, "license": "MIT" }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "license": "MIT" }, - "node_modules/@tsconfig/node20": { - "version": "20.1.8", - "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.8.tgz", - "integrity": "sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A==", + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", "license": "MIT" }, - "node_modules/@types/bluebird": { - "version": "3.5.42", - "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.42.tgz", - "integrity": "sha512-Jhy+MWRlro6UjVi578V/4ZGNfeCOcNCp0YaFNIUGFKlImowqwb1O/22wDVk3FDGMLqxdpOV3qQHD5fPEH4hK6A==", - "license": "MIT" + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } }, - "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==", + "node_modules/@types/ws": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-3.2.1.tgz", + "integrity": "sha512-t5n0/iHoavnX1MqeYmKJgWc1W6yX4BXsNxQg7M5862RWrfN9S5k8yaWbDMGJSTCzbH7+q5QS8chjymd+ND9gMw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/connect": "*", "@types/node": "*" } }, - "node_modules/@types/chalk": { - "version": "0.4.31", - "resolved": "https://registry.npmjs.org/@types/chalk/-/chalk-0.4.31.tgz", - "integrity": "sha512-nF0fisEPYMIyfrFgabFimsz9Lnuu9MwkNrrlATm2E4E46afKDyeelT+8bXfw1VSc7sLBxMxRgT7PxTC2JcqN4Q==", - "dev": true, - "license": "MIT" + "node_modules/@types/xml-encryption": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", + "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, - "node_modules/@types/chrome-remote-interface": { - "version": "0.31.14", - "resolved": "https://registry.npmjs.org/@types/chrome-remote-interface/-/chrome-remote-interface-0.31.14.tgz", - "integrity": "sha512-H9hTcLu1y+Ms6GDPXXeGhgxaOSD69yEo674vjJw5EeW1tTwYo8fEkf7A9nWlnO6ArJsS7c41iZeX6mRDQ1LhEw==", + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", "license": "MIT", "dependencies": { - "devtools-protocol": "0.0.927104" + "@types/node": "*" } }, - "node_modules/@types/connect": { - "version": "3.4.36", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", - "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", + "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/type-utils": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.39.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/cookie": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", - "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", - "license": "MIT" + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } }, - "node_modules/@types/cookie-parser": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", - "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", + "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", + "dev": true, "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, "peerDependencies": { - "@types/express": "*" + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/cookie-session": { - "version": "2.0.49", - "resolved": "https://registry.npmjs.org/@types/cookie-session/-/cookie-session-2.0.49.tgz", - "integrity": "sha512-4E/bBjlqLhU5l4iGPR+NkVJH593hpNsT4dC3DJDr+ODm6Qpe13kZQVkezRIb+TYDXaBMemS3yLQ+0leba3jlkQ==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", + "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", "dev": true, "license": "MIT", "dependencies": { - "@types/express": "*", - "@types/keygrip": "*" + "@typescript-eslint/tsconfig-utils": "^8.39.1", + "@typescript-eslint/types": "^8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", + "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/csurf": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/@types/csurf/-/csurf-1.11.5.tgz", - "integrity": "sha512-5rw87+5YGixyL2W8wblSUl5DSZi5YOlXE6Awwn2ofLvqKr/1LruKffrQipeJKUX44VaxKj8m5es3vfhltJTOoA==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", + "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", "dev": true, "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", + "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", + "dev": true, "license": "MIT", "dependencies": { - "@types/ms": "*" + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "license": "MIT" - }, - "node_modules/@types/eventemitter3": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/eventemitter3/-/eventemitter3-1.2.0.tgz", - "integrity": "sha512-qB+RJcqIM5b8/CfmjqiBylK2KxBGKpRlewjsfq+IVFrDhclWO9JodK4g3NbY0Kpf/3CcLbz+tHMH6bxJEw0Z3A==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "license": "MIT", "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", + "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "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==", - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.16", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz", - "integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@typescript-eslint/types": "8.39.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "license": "MIT" - }, - "node_modules/@types/keygrip": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.6.tgz", - "integrity": "sha512-lZuNAY9xeJt7Bx4t4dx0rYCDqGPW8RXhQZK1td7d4H6E9zYbLoOtjBvfwdTKpsyxQI/2jv+armjX/RW+ZNpXOQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash": { - "version": "4.17.20", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "license": "MIT" - }, - "node_modules/@types/mysql": { - "version": "2.15.26", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", - "integrity": "sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==", + "node_modules/@typescript/vfs": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.1.tgz", + "integrity": "sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA==", "license": "MIT", "dependencies": { - "@types/node": "*" + "debug": "^4.1.1" + }, + "peerDependencies": { + "typescript": "*" } }, - "node_modules/@types/node": { - "version": "22.17.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.17.0.tgz", - "integrity": "sha512-bbAKTCqX5aNVryi7qXVMi+OkB3w/OyblodicMbvE38blyAz7GxXf6XYhklokijuPwwVg9sDLKRxt0ZHXQwZVfQ==", - "license": "MIT", + "node_modules/@u4/adbkit": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@u4/adbkit/-/adbkit-5.1.7.tgz", + "integrity": "sha512-9+m10REz9sZUmo60QsSot2xqLNsT+SlKnFdvgedy/27rWXlsdKsp72ognSHpz37pMzG/Vk2O8SIGdVDWg77NEg==", + "license": "Apache-2.0", "dependencies": { - "undici-types": "~6.21.0" + "@u4/adbkit-logcat": "2.1.2", + "@u4/adbkit-monkey": "^1.0.5", + "@u4/minicap-prebuilt": "^1.1.2", + "@xmldom/xmldom": "^0.9.8", + "commander": "14.0.0", + "debug": "~4.4.1", + "get-port": "7.1.0", + "node-forge": "^1.3.1", + "promise-duplex": "^8.0.0", + "promise-readable": "^8.0.1", + "protobufjs": "^7.5.3", + "xpath": "^0.0.34" + }, + "bin": { + "adbkit": "bin/adbkit" + }, + "engines": { + "node": ">= 12.20.0" + }, + "funding": { + "url": "https://github.com/sponsors/urielch" } }, - "node_modules/@types/normalize-package-data": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", - "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "license": "MIT" + "node_modules/@u4/adbkit-logcat": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@u4/adbkit-logcat/-/adbkit-logcat-2.1.2.tgz", + "integrity": "sha512-mCAuqwWCA2MXYP8nfPJe9IJQ/aTkiFCVvF+qnsBXQ5ceaQOnw2JKNQxjz5Gw7dhYZ9iazvcxg6FNhyec5pfHeQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 12.20.0" + } }, - "node_modules/@types/parse-json": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", - "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT" + "node_modules/@u4/adbkit-monkey": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@u4/adbkit-monkey/-/adbkit-monkey-1.0.5.tgz", + "integrity": "sha512-6Hoz8EeKVK3eEJAGR44yzclADhA/5Q4KDlTtegMY4Dl5VMGq+tm1HtrmBvT++kftfrG64/H7xnrOwQ7DwQ2TlA==", + "license": "Apache-2.0", + "engines": { + "node": ">= 12.20.0" + } }, - "node_modules/@types/passport": { - "version": "1.0.17", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", - "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "node_modules/@u4/adbkit/node_modules/@xmldom/xmldom": { + "version": "0.9.8", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", + "integrity": "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==", "license": "MIT", - "dependencies": { - "@types/express": "*" + "engines": { + "node": ">=14.6" } }, - "node_modules/@types/passport-strategy": { - "version": "0.2.38", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", - "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "node_modules/@u4/adbkit/node_modules/commander": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", + "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", "license": "MIT", - "dependencies": { - "@types/express": "*", - "@types/passport": "*" + "engines": { + "node": ">=20" } }, - "node_modules/@types/pg": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz", - "integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==", + "node_modules/@u4/adbkit/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "license": "MIT", "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/@types/pg-pool": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.6.tgz", - "integrity": "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==", + "node_modules/@u4/adbkit/node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", "license": "MIT", - "dependencies": { - "@types/pg": "*" + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", - "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==", - "license": "MIT" + "node_modules/@u4/adbkit/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/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", - "dev": true, + "node_modules/@u4/adbkit/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/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "node_modules/@u4/adbkit/node_modules/promise-duplex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/promise-duplex/-/promise-duplex-8.0.0.tgz", + "integrity": "sha512-JlrnT6KZMRD+t4cxP7gh/dlGP2pl01/jTPg3pTLvFLBTdHTbTTdc+g25q0qswC3ziO9RAr3L1b2n/++4y23GQg==", "license": "MIT", "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" + "promise-readable": "^8.0.1", + "promise-writable": "^8.0.0" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@types/shimmer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", - "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", - "license": "MIT" - }, - "node_modules/@types/split": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/split/-/split-1.0.5.tgz", - "integrity": "sha512-gMiDr4vA6YofTpAkPQtP+5pvStIf3CMYphf32YAG/3RwogNL8ii1CQKDc+sxN62KuxPoRaJXcf2zDCDkEBH4FA==", - "dev": true, + "node_modules/@u4/adbkit/node_modules/promise-readable": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/promise-readable/-/promise-readable-8.0.1.tgz", + "integrity": "sha512-peVAz+49xRooF1Om2wYSOluKpOApnpiJVOKgQ6IR/ZAWo15tBa4ZtcKKSTAytRiNJVlkvil9NoFhqihY4Sl1Fw==", "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/through": "*" + "engines": { + "node": ">=16.0.0" } }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", + "node_modules/@u4/adbkit/node_modules/promise-writable": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/promise-writable/-/promise-writable-8.0.0.tgz", + "integrity": "sha512-Qn7/5tE42fjLUWevPm/SFmVz26tRdhmxmPXgOJSeIwLnkI2V4u0/t+CrRXb8ic1Jy9qqvOHVYm+sAIgel++eVA==", "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@u4/adbkit/node_modules/protobufjs": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", "dependencies": { - "@types/node": "*" + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" } }, - "node_modules/@types/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/@types/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-+VfWIwrlept2VBTj7Y2wQnI/Xfscy1u8Pyj/puYwss6V1IblXn1x7S0S9eFh6KyBolgLCm+rUFzhFAbdkR691g==", - "dev": true, + "node_modules/@u4/adbkit/node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", "license": "MIT", - "dependencies": { - "@types/node": "*" + "engines": { + "node": ">=0.6.0" } }, - "node_modules/@types/through": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", - "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "node_modules/@u4/minicap-prebuilt": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@u4/minicap-prebuilt/-/minicap-prebuilt-1.1.2.tgz", + "integrity": "sha512-u7hMoELSGCgz8+3k+Us7OnkfosFmkq6OIG/qfPxczTMKl0e6QfHROcJoBE0aPWAE9PEMMfXAooKRZJrc3XRtCw==", + "license": "Apache-2.0" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "license": "MIT" - }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", "license": "MIT", "dependencies": { - "@types/webidl-conversions": "*" + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/ws": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-3.2.1.tgz", - "integrity": "sha512-t5n0/iHoavnX1MqeYmKJgWc1W6yX4BXsNxQg7M5862RWrfN9S5k8yaWbDMGJSTCzbH7+q5QS8chjymd+ND9gMw==", + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/xml-encryption": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz", - "integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==", + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, "license": "MIT", "dependencies": { - "@types/node": "*" + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@types/xml2js": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz", - "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, "license": "MIT", - "dependencies": { - "@types/node": "*" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", - "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/type-utils": "8.39.1", - "@typescript-eslint/utils": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.39.1", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "engines": { - "node": ">= 4" + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", - "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", - "dev": true, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", - "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", - "dev": true, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.39.1", - "@typescript-eslint/types": "^8.39.1", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", - "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", - "dev": true, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "@xtuc/ieee754": "^1.2.0" } }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", - "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" } }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", - "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", - "dev": true, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1", - "@typescript-eslint/utils": "8.39.1", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, - "node_modules/@typescript-eslint/types": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", - "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", - "dev": true, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", - "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", - "dev": true, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.39.1", - "@typescript-eslint/tsconfig-utils": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/visitor-keys": "8.39.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "node_modules/@xmldom/is-dom-node": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", + "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 16" } }, - "node_modules/@typescript-eslint/utils": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", - "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", - "dev": true, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.39.1", - "@typescript-eslint/types": "8.39.1", - "@typescript-eslint/typescript-estree": "8.39.1" - }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" + "node": ">=10.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.39.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", - "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", - "dev": true, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.39.1", - "eslint-visitor-keys": "^4.2.1" + "event-target-shim": "^5.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=6.5" } }, - "node_modules/@typescript/vfs": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.1.tgz", - "integrity": "sha512-JwoxboBh7Oz1v38tPbkrZ62ZXNHAk9bJ7c9x0eI5zBfBnBYGhURdbnh7Z4smN/MV48Y5OCcZb58n972UtbazsA==", + "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": { - "debug": "^4.1.1" - }, - "peerDependencies": { - "typescript": "*" - } - }, - "node_modules/@u4/adbkit": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/@u4/adbkit/-/adbkit-5.1.7.tgz", - "integrity": "sha512-9+m10REz9sZUmo60QsSot2xqLNsT+SlKnFdvgedy/27rWXlsdKsp72ognSHpz37pMzG/Vk2O8SIGdVDWg77NEg==", - "license": "Apache-2.0", - "dependencies": { - "@u4/adbkit-logcat": "2.1.2", - "@u4/adbkit-monkey": "^1.0.5", - "@u4/minicap-prebuilt": "^1.1.2", - "@xmldom/xmldom": "^0.9.8", - "commander": "14.0.0", - "debug": "~4.4.1", - "get-port": "7.1.0", - "node-forge": "^1.3.1", - "promise-duplex": "^8.0.0", - "promise-readable": "^8.0.1", - "protobufjs": "^7.5.3", - "xpath": "^0.0.34" + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "license": "MIT", "bin": { - "adbkit": "bin/adbkit" + "acorn": "bin/acorn" }, "engines": { - "node": ">= 12.20.0" - }, - "funding": { - "url": "https://github.com/sponsors/urielch" + "node": ">=0.4.0" } }, - "node_modules/@u4/adbkit-logcat": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@u4/adbkit-logcat/-/adbkit-logcat-2.1.2.tgz", - "integrity": "sha512-mCAuqwWCA2MXYP8nfPJe9IJQ/aTkiFCVvF+qnsBXQ5ceaQOnw2JKNQxjz5Gw7dhYZ9iazvcxg6FNhyec5pfHeQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 12.20.0" + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" } }, - "node_modules/@u4/adbkit-monkey": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@u4/adbkit-monkey/-/adbkit-monkey-1.0.5.tgz", - "integrity": "sha512-6Hoz8EeKVK3eEJAGR44yzclADhA/5Q4KDlTtegMY4Dl5VMGq+tm1HtrmBvT++kftfrG64/H7xnrOwQ7DwQ2TlA==", - "license": "Apache-2.0", + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", "engines": { - "node": ">= 12.20.0" + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" } }, - "node_modules/@u4/adbkit/node_modules/@xmldom/xmldom": { - "version": "0.9.8", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.8.tgz", - "integrity": "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=14.6" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/@u4/adbkit/node_modules/commander": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.0.tgz", - "integrity": "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "license": "MIT", "engines": { - "node": ">=20" + "node": ">= 14" } }, - "node_modules/@u4/adbkit/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, - "engines": { - "node": ">=6.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" }, "peerDependenciesMeta": { - "supports-color": { + "ajv": { "optional": true } } }, - "node_modules/@u4/adbkit/node_modules/get-port": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", - "engines": { - "node": ">=16" + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@u4/adbkit/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/@u4/adbkit/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==", + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/@u4/adbkit/node_modules/promise-duplex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/promise-duplex/-/promise-duplex-8.0.0.tgz", - "integrity": "sha512-JlrnT6KZMRD+t4cxP7gh/dlGP2pl01/jTPg3pTLvFLBTdHTbTTdc+g25q0qswC3ziO9RAr3L1b2n/++4y23GQg==", + "node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", "license": "MIT", - "dependencies": { - "promise-readable": "^8.0.1", - "promise-writable": "^8.0.0" - }, "engines": { - "node": ">=16.0.0" + "node": ">=0.10.0" } }, - "node_modules/@u4/adbkit/node_modules/promise-readable": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/promise-readable/-/promise-readable-8.0.1.tgz", - "integrity": "sha512-peVAz+49xRooF1Om2wYSOluKpOApnpiJVOKgQ6IR/ZAWo15tBa4ZtcKKSTAytRiNJVlkvil9NoFhqihY4Sl1Fw==", + "node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", "license": "MIT", "engines": { - "node": ">=16.0.0" + "node": ">=0.10.0" } }, - "node_modules/@u4/adbkit/node_modules/promise-writable": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/promise-writable/-/promise-writable-8.0.0.tgz", - "integrity": "sha512-Qn7/5tE42fjLUWevPm/SFmVz26tRdhmxmPXgOJSeIwLnkI2V4u0/t+CrRXb8ic1Jy9qqvOHVYm+sAIgel++eVA==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" }, - "node_modules/@u4/adbkit/node_modules/protobufjs": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", - "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", + "node_modules/appium-ios-device": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/appium-ios-device/-/appium-ios-device-3.1.9.tgz", + "integrity": "sha512-j4zNwszDvBHqyZKqX99RLcif3CnuYDpVKA9E2DBM/4mOFYT+o3bPAMrPZRx2Tt3RImoTCUrTCUinYopsPqX2Eg==", + "license": "Apache-2.0", "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" + "@appium/support": "^7.0.0-rc.1", + "asyncbox": "^6.0.1", + "axios": "^1.6.7", + "bluebird": "^3.1.1", + "bplist-creator": "^0.x", + "bplist-parser": "^0.x", + "lodash": "^4.17.15", + "semver": "^7.0.0" }, "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@u4/adbkit/node_modules/xpath": { - "version": "0.0.34", - "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", - "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", - "license": "MIT", - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/@u4/minicap-prebuilt": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@u4/minicap-prebuilt/-/minicap-prebuilt-1.1.2.tgz", - "integrity": "sha512-u7hMoELSGCgz8+3k+Us7OnkfosFmkq6OIG/qfPxczTMKl0e6QfHROcJoBE0aPWAE9PEMMfXAooKRZJrc3XRtCw==", - "license": "Apache-2.0" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-ios-device/node_modules/asyncbox": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.1.0.tgz", + "integrity": "sha512-KZwKNVnDdDe0ubN+fFMuHhSljZNHnbjdJABImoqFzQP61oIg6sMlhXIqOIu3WRd7YwW89q+eVj2Ty/Ax5dbh2Q==", + "license": "Apache-2.0", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" + "p-limit": "^7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "node_modules/appium-ios-device/node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "node_modules/appium-ios-device/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "node_modules/appium-ios-device/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "node_modules/appium-ios-simulator": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/appium-ios-simulator/-/appium-ios-simulator-8.0.11.tgz", + "integrity": "sha512-DMm+XyS4o4iNPJwcGI7eecfmH6B7E8Q/1pANjWIOQhac3hOdUdo1KZxbnIQ4lUbSLzvLWL1T5Rts1UP//Jtwtg==", "license": "Apache-2.0", "dependencies": { - "@xtuc/long": "4.2.2" + "@appium/support": "^7.0.0-rc.1", + "@xmldom/xmldom": "^0.x", + "appium-xcode": "^6.0.0", + "async-lock": "^1.0.0", + "asyncbox": "^6.0.1", + "bluebird": "^3.5.1", + "lodash": "^4.2.1", + "node-simctl": "^8.1.1", + "semver": "^7.0.0", + "teen_process": "^4.0.4" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/@appium/logger": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-2.0.4.tgz", + "integrity": "sha512-DH4IXGjn/9q4BfrbPdacremNS2FZXIu8vMQyUChWfP+rVRJ1TKMxXF1/ZXNpOfuu0FRrknclO96tUWG3HJj9Xg==", + "license": "ISC", "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" + "console-control-strings": "1.1.0", + "lodash": "4.17.23", + "lru-cache": "11.2.5", + "set-blocking": "2.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/asyncbox": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.1.0.tgz", + "integrity": "sha512-KZwKNVnDdDe0ubN+fFMuHhSljZNHnbjdJABImoqFzQP61oIg6sMlhXIqOIu3WRd7YwW89q+eVj2Ty/Ax5dbh2Q==", + "license": "Apache-2.0", "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" + "p-limit": "^7.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } + "node_modules/appium-ios-simulator/node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/glob": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "license": "BlueOak-1.0.0", "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" + "minimatch": "^10.1.2", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" + "node_modules/appium-ios-simulator/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" } }, - "node_modules/@xmldom/is-dom-node": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz", - "integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">= 16" + "node": "20 || >=22" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/minimatch": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.1" + }, "engines": { - "node": ">=10.0.0" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause" + "node_modules/appium-ios-simulator/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0" + "node_modules/appium-ios-simulator/node_modules/node-simctl": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-8.1.5.tgz", + "integrity": "sha512-8lQlne56cXGpPHjv49QXLQSOJuH+onlxHemlguSsutwbSdW+/ChC+xX932BEoG3qx62fpMPzRj3v2I1wVT4Ezw==", + "license": "Apache-2.0", + "dependencies": { + "@appium/logger": "^2.0.0-rc.1", + "asyncbox": "^6.0.1", + "bluebird": "^3.5.1", + "lodash": "^4.2.1", + "rimraf": "^6.0.1", + "semver": "^7.0.0", + "teen_process": "^4.0.4", + "uuid": "^13.0.0", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "node_modules/appium-ios-simulator/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", "license": "MIT", "dependencies": { - "event-target-shim": "^5.0.0" + "yocto-queue": "^1.2.1" }, "engines": { - "node": ">=6.5" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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", + "node_modules/appium-ios-simulator/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "license": "BlueOak-1.0.0", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">= 0.6" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, "bin": { - "acorn": "bin/acorn" + "rimraf": "dist/esm/bin.mjs" }, "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "engines": { - "node": ">=10.13.0" + "node_modules/appium-ios-simulator/node_modules/teen_process": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.9.tgz", + "integrity": "sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "shell-quote": "^1.8.1" }, - "peerDependencies": { - "acorn": "^8.14.0" + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, + "node_modules/appium-ios-simulator/node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "bin": { + "uuid": "dist-node/bin/uuid" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", + "node_modules/appium-ios-simulator/node_modules/which": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, "engines": { - "node": ">= 14" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "node_modules/appium-ios-simulator/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "engines": { + "node": ">=12.20" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", + "node_modules/appium-sdb": { + "version": "1.0.1-beta.1", + "resolved": "https://registry.npmjs.org/appium-sdb/-/appium-sdb-1.0.1-beta.1.tgz", + "integrity": "sha512-jciaEYrtZR7+NeB/KXrVLfQfvCQRdMpI4ZvrJWqyDOcUkzw5emH6nTGPTbkvnG9cf4opWM/e4wsgmHYnjgjByQ==", + "engines": [ + "node" + ], + "license": "Apache-2.0", "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } + "@babel/runtime": "^7.0.0", + "appium-support": "^2.4.0", + "asyncbox": "^2.3.1", + "lodash": "^4.17.11", + "source-map-support": "^0.5.9", + "teen_process": "^1.3.1" } }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", - "license": "MIT", + "node_modules/appium-support": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/appium-support/-/appium-support-2.55.0.tgz", + "integrity": "sha512-QA8lcJr9fagZ9votEGRInjGxHAIzqYr2WVo7WOe0zYwIVZNK5r2oBTHwjwtRm6mXKf3q5z3A6/hdiYeNBlaBpA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "engines": [ + "node" + ], + "license": "Apache-2.0", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "@babel/runtime": "^7.0.0", + "archiver": "^5.0.0", + "axios": "^0.x", + "base64-stream": "^1.0.0", + "bluebird": "^3.5.1", + "bplist-creator": "^0", + "bplist-parser": "^0.x", + "form-data": "^4.0.0", + "get-stream": "^6.0.0", + "glob": "^7.1.2", + "jimp": "^0.x", + "jsftp": "^2.1.2", + "klaw": "^3.0.0", + "lockfile": "^1.0.4", + "lodash": "^4.2.1", + "mkdirp": "^1.0.0", + "moment": "^2.24.0", + "mv": "^2.1.1", + "ncp": "^2.0.0", + "npmlog": "^6.0.0", + "plist": "^3.0.1", + "pluralize": "^8.0.0", + "pngjs": "^6.0.0", + "rimraf": "^3.0.0", + "sanitize-filename": "^1.6.1", + "semver": "^7.0.0", + "shell-quote": "^1.7.2", + "source-map-support": "^0.5.5", + "teen_process": "^1.5.1", + "uuid": "^8.0.0", + "which": "^2.0.0", + "yauzl": "^2.7.0" } }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "node_modules/appium-support/node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, - "node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node_modules/appium-support/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/any-base": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", - "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", - "license": "MIT" + "node_modules/appium-support/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } }, - "node_modules/appium-ios-device": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/appium-ios-device/-/appium-ios-device-3.1.9.tgz", - "integrity": "sha512-j4zNwszDvBHqyZKqX99RLcif3CnuYDpVKA9E2DBM/4mOFYT+o3bPAMrPZRx2Tt3RImoTCUrTCUinYopsPqX2Eg==", + "node_modules/appium-webdriveragent": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/appium-webdriveragent/-/appium-webdriveragent-11.1.4.tgz", + "integrity": "sha512-5NmVn2Qi7jezSXEHAOLt3E5qnp/9Mv9v1nzdbvjn8YN0O15pACEz3eqN1SBpjQ8A3pR8jBW7h4olJLoTs/+Y+A==", "license": "Apache-2.0", "dependencies": { + "@appium/base-driver": "^10.0.0-rc.1", + "@appium/strongbox": "^1.0.0-rc.1", "@appium/support": "^7.0.0-rc.1", - "asyncbox": "^6.0.1", - "axios": "^1.6.7", - "bluebird": "^3.1.1", - "bplist-creator": "^0.x", - "bplist-parser": "^0.x", - "lodash": "^4.17.15", - "semver": "^7.0.0" + "appium-ios-device": "^3.0.0", + "appium-ios-simulator": "^8.0.0", + "async-lock": "^1.0.0", + "asyncbox": "^6.1.0", + "axios": "^1.4.0", + "bluebird": "^3.5.5", + "lodash": "^4.17.11", + "teen_process": "^4.0.7" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0", "npm": ">=10" } }, - "node_modules/appium-ios-device/node_modules/asyncbox": { + "node_modules/appium-webdriveragent/node_modules/asyncbox": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.1.0.tgz", "integrity": "sha512-KZwKNVnDdDe0ubN+fFMuHhSljZNHnbjdJABImoqFzQP61oIg6sMlhXIqOIu3WRd7YwW89q+eVj2Ty/Ax5dbh2Q==", @@ -7094,13 +7953,13 @@ "npm": ">=10" } }, - "node_modules/appium-ios-device/node_modules/bluebird": { + "node_modules/appium-webdriveragent/node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, - "node_modules/appium-ios-device/node_modules/p-limit": { + "node_modules/appium-webdriveragent/node_modules/p-limit": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", @@ -7115,7 +7974,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/appium-ios-device/node_modules/yocto-queue": { + "node_modules/appium-webdriveragent/node_modules/teen_process": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.9.tgz", + "integrity": "sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "shell-quote": "^1.8.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-webdriveragent/node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", @@ -7127,20 +8000,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/appium-ios-simulator": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/appium-ios-simulator/-/appium-ios-simulator-8.0.11.tgz", - "integrity": "sha512-DMm+XyS4o4iNPJwcGI7eecfmH6B7E8Q/1pANjWIOQhac3hOdUdo1KZxbnIQ4lUbSLzvLWL1T5Rts1UP//Jtwtg==", + "node_modules/appium-xcode": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/appium-xcode/-/appium-xcode-6.1.8.tgz", + "integrity": "sha512-nk86u0wo4ZPCxQsiF/1PR/HewpB5NxSkNxUttRLiWEbMTA8FPqDlbUfrD/xaywMYUYkYIk+W9ufz3Gg0CKMBAA==", "license": "Apache-2.0", "dependencies": { "@appium/support": "^7.0.0-rc.1", - "@xmldom/xmldom": "^0.x", - "appium-xcode": "^6.0.0", - "async-lock": "^1.0.0", "asyncbox": "^6.0.1", - "bluebird": "^3.5.1", - "lodash": "^4.2.1", - "node-simctl": "^8.1.1", + "bluebird": "^3.7.2", + "lodash": "^4.17.4", + "plist": "^3.0.1", "semver": "^7.0.0", "teen_process": "^4.0.4" }, @@ -7149,23 +8019,7 @@ "npm": ">=10" } }, - "node_modules/appium-ios-simulator/node_modules/@appium/logger": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-2.0.4.tgz", - "integrity": "sha512-DH4IXGjn/9q4BfrbPdacremNS2FZXIu8vMQyUChWfP+rVRJ1TKMxXF1/ZXNpOfuu0FRrknclO96tUWG3HJj9Xg==", - "license": "ISC", - "dependencies": { - "console-control-strings": "1.1.0", - "lodash": "4.17.23", - "lru-cache": "11.2.5", - "set-blocking": "2.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" - } - }, - "node_modules/appium-ios-simulator/node_modules/asyncbox": { + "node_modules/appium-xcode/node_modules/asyncbox": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.1.0.tgz", "integrity": "sha512-KZwKNVnDdDe0ubN+fFMuHhSljZNHnbjdJABImoqFzQP61oIg6sMlhXIqOIu3WRd7YwW89q+eVj2Ty/Ax5dbh2Q==", @@ -7178,1491 +8032,1671 @@ "npm": ">=10" } }, - "node_modules/appium-ios-simulator/node_modules/bluebird": { + "node_modules/appium-xcode/node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, - "node_modules/appium-ios-simulator/node_modules/glob": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", - "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", - "license": "BlueOak-1.0.0", + "node_modules/appium-xcode/node_modules/p-limit": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", + "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "license": "MIT", "dependencies": { - "minimatch": "^10.1.2", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "yocto-queue": "^1.2.1" }, "engines": { - "node": "20 || >=22" + "node": ">=20" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/appium-ios-simulator/node_modules/isexe": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", - "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", - "license": "ISC", + "node_modules/appium-xcode/node_modules/teen_process": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.9.tgz", + "integrity": "sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "shell-quote": "^1.8.1" + }, "engines": { - "node": ">=16" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" } }, - "node_modules/appium-ios-simulator/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "license": "BlueOak-1.0.0", + "node_modules/appium-xcode/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/appium-ios-simulator/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", - "license": "BlueOak-1.0.0", + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 10" } }, - "node_modules/appium-ios-simulator/node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">= 6" } }, - "node_modules/appium-ios-simulator/node_modules/node-simctl": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-8.1.5.tgz", - "integrity": "sha512-8lQlne56cXGpPHjv49QXLQSOJuH+onlxHemlguSsutwbSdW+/ChC+xX932BEoG3qx62fpMPzRj3v2I1wVT4Ezw==", - "license": "Apache-2.0", + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", "dependencies": { - "@appium/logger": "^2.0.0-rc.1", - "asyncbox": "^6.0.1", - "bluebird": "^3.5.1", - "lodash": "^4.2.1", - "rimraf": "^6.0.1", - "semver": "^7.0.0", - "teen_process": "^4.0.4", - "uuid": "^13.0.0", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" } }, - "node_modules/appium-ios-simulator/node_modules/p-limit": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", - "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "license": "MIT", "dependencies": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "safe-buffer": "~5.1.0" } }, - "node_modules/appium-ios-simulator/node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", - "license": "BlueOak-1.0.0", + "node_modules/archiver/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/appium-ios-simulator/node_modules/rimraf": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", - "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", - "license": "BlueOak-1.0.0", + "node_modules/are-we-there-yet/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": { - "glob": "^13.0.0", - "package-json-from-dist": "^1.0.1" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" }, "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 6" } }, - "node_modules/appium-ios-simulator/node_modules/teen_process": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.9.tgz", - "integrity": "sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==", + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "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/array-parallel": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", + "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==", + "license": "MIT" + }, + "node_modules/array-series": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", + "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==", + "license": "MIT" + }, + "node_modules/ascli": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ascli/-/ascli-1.0.1.tgz", + "integrity": "sha512-JGQaNxpaCJz9Bd1JvVaFIHuWn9S+l3xhN17R0V/vmUDiGE0QngNMXhjlqpwqV+91plWz9Fg+Lt28Lj7p5vjs8A==", "license": "Apache-2.0", "dependencies": { - "lodash": "^4.17.21", - "shell-quote": "^1.8.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "colour": "~0.7.1", + "optjs": "~3.2.2" } }, - "node_modules/appium-ios-simulator/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], + "node_modules/asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", + "license": "MIT" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" + "engines": { + "node": ">=0.8" } }, - "node_modules/appium-ios-simulator/node_modules/which": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", - "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=12" } }, - "node_modules/appium-ios-simulator/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "bin": { + "astring": "bin/astring" } }, - "node_modules/appium-sdb": { - "version": "1.0.1-beta.1", - "resolved": "https://registry.npmjs.org/appium-sdb/-/appium-sdb-1.0.1-beta.1.tgz", - "integrity": "sha512-jciaEYrtZR7+NeB/KXrVLfQfvCQRdMpI4ZvrJWqyDOcUkzw5emH6nTGPTbkvnG9cf4opWM/e4wsgmHYnjgjByQ==", - "engines": [ - "node" - ], - "license": "Apache-2.0", + "node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.0.0", - "appium-support": "^2.4.0", - "asyncbox": "^2.3.1", - "lodash": "^4.17.11", - "source-map-support": "^0.5.9", - "teen_process": "^1.3.1" + "lodash": "^4.17.14" } }, - "node_modules/appium-support": { - "version": "2.55.0", - "resolved": "https://registry.npmjs.org/appium-support/-/appium-support-2.55.0.tgz", - "integrity": "sha512-QA8lcJr9fagZ9votEGRInjGxHAIzqYr2WVo7WOe0zYwIVZNK5r2oBTHwjwtRm6mXKf3q5z3A6/hdiYeNBlaBpA==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "engines": [ - "node" - ], + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, + "node_modules/asyncbox": { + "version": "2.9.4", + "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-2.9.4.tgz", + "integrity": "sha512-TCuA73K6Gvn+5tFGsWf4jc+PsR9RmYXw/AF0mv+CRB3VhHLjqHh/w9gPvYILnV0RcRFfjADHtzZexpxWlsP3Tg==", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.0.0", - "archiver": "^5.0.0", - "axios": "^0.x", - "base64-stream": "^1.0.0", + "@types/bluebird": "^3.5.37", "bluebird": "^3.5.1", - "bplist-creator": "^0", - "bplist-parser": "^0.x", - "form-data": "^4.0.0", - "get-stream": "^6.0.0", - "glob": "^7.1.2", - "jimp": "^0.x", - "jsftp": "^2.1.2", - "klaw": "^3.0.0", - "lockfile": "^1.0.4", - "lodash": "^4.2.1", - "mkdirp": "^1.0.0", - "moment": "^2.24.0", - "mv": "^2.1.1", - "ncp": "^2.0.0", - "npmlog": "^6.0.0", - "plist": "^3.0.1", - "pluralize": "^8.0.0", - "pngjs": "^6.0.0", - "rimraf": "^3.0.0", - "sanitize-filename": "^1.6.1", - "semver": "^7.0.0", - "shell-quote": "^1.7.2", - "source-map-support": "^0.5.5", - "teen_process": "^1.5.1", - "uuid": "^8.0.0", - "which": "^2.0.0", - "yauzl": "^2.7.0" + "lodash": "^4.17.4", + "source-map-support": "^0.5.5" + }, + "engines": { + "node": ">=10" } }, - "node_modules/appium-support/node_modules/bluebird": { + "node_modules/asyncbox/node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, - "node_modules/appium-support/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", + "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "license": "MIT", "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } } }, - "node_modules/appium-support/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/appium-webdriveragent": { - "version": "11.1.4", - "resolved": "https://registry.npmjs.org/appium-webdriveragent/-/appium-webdriveragent-11.1.4.tgz", - "integrity": "sha512-5NmVn2Qi7jezSXEHAOLt3E5qnp/9Mv9v1nzdbvjn8YN0O15pACEz3eqN1SBpjQ8A3pR8jBW7h4olJLoTs/+Y+A==", - "license": "Apache-2.0", "dependencies": { - "@appium/base-driver": "^10.0.0-rc.1", - "@appium/strongbox": "^1.0.0-rc.1", - "@appium/support": "^7.0.0-rc.1", - "appium-ios-device": "^3.0.0", - "appium-ios-simulator": "^8.0.0", - "async-lock": "^1.0.0", - "asyncbox": "^6.1.0", - "axios": "^1.4.0", - "bluebird": "^3.5.5", - "lodash": "^4.17.11", - "teen_process": "^4.0.7" + "precond": "0.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "node": ">= 0.6" } }, - "node_modules/appium-webdriveragent/node_modules/asyncbox": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.1.0.tgz", - "integrity": "sha512-KZwKNVnDdDe0ubN+fFMuHhSljZNHnbjdJABImoqFzQP61oIg6sMlhXIqOIu3WRd7YwW89q+eVj2Ty/Ax5dbh2Q==", + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", - "dependencies": { - "p-limit": "^7.2.0" + "peerDependencies": { + "bare-abort-controller": "*" }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } } }, - "node_modules/appium-webdriveragent/node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "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/appium-webdriveragent/node_modules/p-limit": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", - "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "node_modules/base64-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/base64-stream/-/base64-stream-1.0.0.tgz", + "integrity": "sha512-BQQZftaO48FcE1Kof9CmXMFaAdqkcNorgc8CxesZv9nMbbTF1EFyQe89UOuh//QMmdtfUDXyO8rgUalemL5ODA==", + "license": "MIT" + }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", "license": "MIT", - "dependencies": { - "yocto-queue": "^1.2.1" - }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^4.5.0 || >= 5.9" } }, - "node_modules/appium-webdriveragent/node_modules/teen_process": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.9.tgz", - "integrity": "sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash": "^4.17.21", - "shell-quote": "^1.8.1" - }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "node": ">=6.0.0" } }, - "node_modules/appium-webdriveragent/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "node_modules/basic-auth": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", + "integrity": "sha512-CtGuTyWf3ig+sgRyC7uP6DM3N+5ur/p8L+FPfsd+BbIfIs74TFfCajZTHnCw6K5dqM0bZEbRIqRy1fAdiUJhTA==", "license": "MIT", "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/appium-xcode": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/appium-xcode/-/appium-xcode-6.1.8.tgz", - "integrity": "sha512-nk86u0wo4ZPCxQsiF/1PR/HewpB5NxSkNxUttRLiWEbMTA8FPqDlbUfrD/xaywMYUYkYIk+W9ufz3Gg0CKMBAA==", - "license": "Apache-2.0", + "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": { - "@appium/support": "^7.0.0-rc.1", - "asyncbox": "^6.0.1", - "bluebird": "^3.7.2", - "lodash": "^4.17.4", - "plist": "^3.0.1", - "semver": "^7.0.0", - "teen_process": "^4.0.4" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "tweetnacl": "^0.14.3" } }, - "node_modules/appium-xcode/node_modules/asyncbox": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-6.1.0.tgz", - "integrity": "sha512-KZwKNVnDdDe0ubN+fFMuHhSljZNHnbjdJABImoqFzQP61oIg6sMlhXIqOIu3WRd7YwW89q+eVj2Ty/Ax5dbh2Q==", - "license": "Apache-2.0", - "dependencies": { - "p-limit": "^7.2.0" - }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "node": ">=0.6" } }, - "node_modules/appium-xcode/node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" - }, - "node_modules/appium-xcode/node_modules/p-limit": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", - "integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==", + "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": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" } }, - "node_modules/appium-xcode/node_modules/teen_process": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.9.tgz", - "integrity": "sha512-AdH4nuHQTTiFEnib3wWnepnfa7Vz8QzOZ7EsLM8iz8pOlZmshjnODmWTt/8OA6v6A9gACURKc0OddGX28UoxFQ==", - "license": "Apache-2.0", + "node_modules/bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", + "license": "MIT" + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", "dependencies": { - "lodash": "^4.17.21", - "shell-quote": "^1.8.1" + "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.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": ">=10" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/appium-xcode/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "node_modules/body-parser/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", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "license": "ISC" + "node_modules/body-parser/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/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, + "node_modules/bplist-creator": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.1.tgz", + "integrity": "sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ==", "license": "MIT", "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" - }, - "engines": { - "node": ">= 10" + "stream-buffers": "2.2.x" } }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", "license": "MIT", "dependencies": { - "glob": "^7.1.4", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" + "big-integer": "1.6.x" }, "engines": { - "node": ">= 6" + "node": ">= 5.10.0" } }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "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==", + "dev": true, "license": "MIT", "dependencies": { - "safe-buffer": "~5.1.0" + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/archiver/node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", - "license": "ISC", + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "base64-js": "^1.1.2" } }, - "node_modules/are-we-there-yet/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==", + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">= 6" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } }, - "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/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/array-parallel": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/array-parallel/-/array-parallel-0.1.3.tgz", - "integrity": "sha512-TDPTwSWW5E4oiFiKmz6RGJ/a80Y91GuLgUYuLd49+XBS75tYo8PNgaT2K/OxuQYqkoI852MDGBorg9OcUSTQ8w==", - "license": "MIT" + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } }, - "node_modules/array-series": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/array-series/-/array-series-0.1.5.tgz", - "integrity": "sha512-L0XlBwfx9QetHOsbLDrE/vh2t018w9462HM3iaFfxRiK83aJjAt/Ja3NMkOW7FICwWTlQBa3ZbL5FKhuQWkDrg==", - "license": "MIT" + "node_modules/buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } }, - "node_modules/ascli": { + "node_modules/buffer-equal-constant-time": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ascli/-/ascli-1.0.1.tgz", - "integrity": "sha512-JGQaNxpaCJz9Bd1JvVaFIHuWn9S+l3xhN17R0V/vmUDiGE0QngNMXhjlqpwqV+91plWz9Fg+Lt28Lj7p5vjs8A==", - "license": "Apache-2.0", - "dependencies": { - "colour": "~0.7.1", - "optjs": "~3.2.2" - } + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" }, - "node_modules/asn1": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", - "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "node_modules/bufferutil": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", + "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", + "hasInstallScript": true, "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, "engines": { - "node": ">=0.8" + "node": ">=6.14.2" } }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dev": true, + "node_modules/bunyan": { + "version": "1.8.15", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", + "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", + "engines": [ + "node >=0.10.0" + ], "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" + "bin": { + "bunyan": "bin/bunyan" + }, + "optionalDependencies": { + "dtrace-provider": "~0.8", + "moment": "^2.19.3", + "mv": "~2", + "safe-json-stringify": "~1" } }, - "node_modules/async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "license": "MIT" - }, - "node_modules/async-lock": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", - "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", - "license": "MIT" - }, - "node_modules/asyncbox": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/asyncbox/-/asyncbox-2.9.4.tgz", - "integrity": "sha512-TCuA73K6Gvn+5tFGsWf4jc+PsR9RmYXw/AF0mv+CRB3VhHLjqHh/w9gPvYILnV0RcRFfjADHtzZexpxWlsP3Tg==", + "node_modules/bytebuffer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz", + "integrity": "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==", "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.0.0", - "@types/bluebird": "^3.5.37", - "bluebird": "^3.5.1", - "lodash": "^4.17.4", - "source-map-support": "^0.5.5" + "long": "~3" }, "engines": { - "node": ">=10" + "node": ">=0.8" } }, - "node_modules/asyncbox/node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "license": "Apache-2.0", + "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": "*" + "node": ">= 0.8" } }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz", - "integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==", + "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": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/b4a": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", - "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", - "license": "Apache-2.0", - "peerDependencies": { - "react-native-b4a": "*" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" }, - "peerDependenciesMeta": { - "react-native-b4a": { - "optional": true - } + "engines": { + "node": ">= 0.4" } }, - "node_modules/backoff": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", - "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "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": { - "precond": "0.2" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } }, - "node_modules/bare-events": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", - "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", - "license": "Apache-2.0", - "peerDependencies": { - "bare-abort-controller": "*" - }, - "peerDependenciesMeta": { - "bare-abort-controller": { - "optional": true - } + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, - "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==", + "node_modules/caniuse-lite": { + "version": "1.0.30001731", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", + "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/browserslist" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], - "license": "MIT" + "license": "CC-BY-4.0" }, - "node_modules/base64-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/base64-stream/-/base64-stream-1.0.0.tgz", - "integrity": "sha512-BQQZftaO48FcE1Kof9CmXMFaAdqkcNorgc8CxesZv9nMbbTF1EFyQe89UOuh//QMmdtfUDXyO8rgUalemL5ODA==", - "license": "MIT" + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "node_modules/centra": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" + "dependencies": { + "follow-redirects": "^1.15.6" } }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "node_modules/centra/node_modules/follow-redirects": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.10.tgz", + "integrity": "sha512-V7O/fFKM539IC2bweloFWuoiJ9OtI3W2uIqJPWM8IT5xxNyt73QtvVqmSpcDmk07ivmmlKB+rRY0vpQjIYNtKw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], "license": "MIT", "engines": { - "node": ">=6.0.0" + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } } }, - "node_modules/basic-auth": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-1.1.0.tgz", - "integrity": "sha512-CtGuTyWf3ig+sgRyC7uP6DM3N+5ur/p8L+FPfsd+BbIfIs74TFfCajZTHnCw6K5dqM0bZEbRIqRy1fAdiUJhTA==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "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", + "node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "license": "MIT", "dependencies": { - "tweetnacl": "^0.14.3" + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, - "node_modules/big-integer": { - "version": "1.6.52", - "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", - "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", - "license": "Unlicense", + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", "engines": { - "node": ">=0.6" + "node": ">=10" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "node_modules/chrome-remote-interface": { + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz", + "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==", "license": "MIT", "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" + "commander": "2.11.x", + "ws": "^7.2.0" + }, + "bin": { + "chrome-remote-interface": "bin/client.js" } }, - "node_modules/bluebird": { + "node_modules/chrome-remote-interface/node_modules/commander": { "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", - "integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==", - "license": "MIT" - }, - "node_modules/bmp-js": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", - "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", "license": "MIT" }, - "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "node_modules/chrome-remote-interface/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "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.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=6.0" } }, - "node_modules/body-parser/node_modules/ms": { + "node_modules/ci-info": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "license": "MIT" }, - "node_modules/bowser": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", - "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "license": "MIT" }, - "node_modules/bplist-creator": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.1.tgz", - "integrity": "sha512-Ese7052fdWrxp/vqSJkydgx/1MdBnNOCV2XVfbmdGWD2H6EYza+Q4pyYSuVSnCUD22hfI/BFI4jHaC3NLXLlJQ==", + "node_modules/cli-docs-generator": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cli-docs-generator/-/cli-docs-generator-1.0.7.tgz", + "integrity": "sha512-b0PkG+SNBfonYM+MYqxfxV4W9HYIP9NSZS8Ns5puuLYTgaiiBHHorHeDmLNkkn+atOf6wBxsp4dLPENtPZBPDw==", + "dev": true, "license": "MIT", "dependencies": { - "stream-buffers": "2.2.x" + "yargs": "^14.2.0" + }, + "bin": { + "cli-docs-generator": "src/cli.js" } }, - "node_modules/bplist-parser": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", - "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "node_modules/cli-docs-generator/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-docs-generator/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, "license": "MIT", "dependencies": { - "big-integer": "1.6.x" + "color-convert": "^1.9.0" }, "engines": { - "node": ">= 5.10.0" + "node": ">=4" } }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "license": "MIT", + "node_modules/cli-docs-generator/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "license": "ISC", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" } }, - "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==", + "node_modules/cli-docs-generator/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-docs-generator/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", "dev": true, "license": "MIT", "dependencies": { - "fill-range": "^7.1.1" + "locate-path": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=6" } }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "node_modules/cli-docs-generator/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, "license": "MIT", - "dependencies": { - "base64-js": "^1.1.2" + "engines": { + "node": ">=4" } }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/cli-docs-generator/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" }, - "bin": { - "browserslist": "cli.js" + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-docs-generator/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", - "license": "Apache-2.0", + "node_modules/cli-docs-generator/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, "engines": { - "node": ">=16.20.1" + "node": ">=6" } }, - "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" - } - ], + "node_modules/cli-docs-generator/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "engines": { + "node": ">=4" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "node_modules/cli-docs-generator/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, "engines": { - "node": "*" + "node": ">=6" } }, - "node_modules/buffer-equal": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-0.0.1.tgz", - "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==", + "node_modules/cli-docs-generator/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, "engines": { - "node": ">=0.4.0" + "node": ">=6" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bufferutil": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.7.tgz", - "integrity": "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw==", - "hasInstallScript": true, + "node_modules/cli-docs-generator/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, "license": "MIT", "dependencies": { - "node-gyp-build": "^4.3.0" + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" }, "engines": { - "node": ">=6.14.2" + "node": ">=6" } }, - "node_modules/bunyan": { - "version": "1.8.15", - "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.15.tgz", - "integrity": "sha512-0tECWShh6wUysgucJcBAoYegf3JJoZWibxdqhTm7OHPeT42qdjkZ29QCMcKwbgU1kiH+auSIasNRXMLWXafXig==", - "engines": [ - "node >=0.10.0" - ], + "node_modules/cli-docs-generator/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/cli-docs-generator/node_modules/yargs": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", + "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", + "dev": true, "license": "MIT", - "bin": { - "bunyan": "bin/bunyan" - }, - "optionalDependencies": { - "dtrace-provider": "~0.8", - "moment": "^2.19.3", - "mv": "~2", - "safe-json-stringify": "~1" + "dependencies": { + "cliui": "^5.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^15.0.1" } }, - "node_modules/bytebuffer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz", - "integrity": "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==", - "license": "Apache-2.0", + "node_modules/cli-docs-generator/node_modules/yargs-parser": { + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.3.tgz", + "integrity": "sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==", + "dev": true, + "license": "ISC", "dependencies": { - "long": "~3" + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=0.8" + "node": ">=20" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "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==", + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "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==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, + "node_modules/cmake-js": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-7.3.1.tgz", + "integrity": "sha512-aJtHDrTFl8qovjSSqXT9aC2jdGfmP8JQsPtjdLAXFfH1BF4/ImZ27Jx0R61TFg8Apc3pl6e2yBKMveAeRXx2Rw==", "license": "MIT", + "dependencies": { + "axios": "^1.6.5", + "debug": "^4", + "fs-extra": "^11.2.0", + "memory-stream": "^1.0.0", + "node-api-headers": "^1.1.0", + "npmlog": "^6.0.2", + "rc": "^1.2.7", + "semver": "^7.5.4", + "tar": "^6.2.0", + "url-join": "^4.0.1", + "which": "^2.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "cmake-js": "bin/cmake-js" + }, "engines": { - "node": ">=6" + "node": ">= 14.15.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "license": "Apache-2.0" - }, - "node_modules/centra": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/centra/-/centra-2.7.0.tgz", - "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "node_modules/cmake-js/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/cmake-js/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": { - "follow-redirects": "^1.15.6" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" } }, - "node_modules/centra/node_modules/follow-redirects": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.10.tgz", - "integrity": "sha512-V7O/fFKM539IC2bweloFWuoiJ9OtI3W2uIqJPWM8IT5xxNyt73QtvVqmSpcDmk07ivmmlKB+rRY0vpQjIYNtKw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], + "node_modules/cmake-js/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", "license": "MIT", - "engines": { - "node": ">=4.0" + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "engines": { + "node": ">=14.14" } }, - "node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "node_modules/cmake-js/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "license": "MIT", "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" + "universalify": "^2.0.0" }, - "engines": { - "node": ">=0.10.0" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "node_modules/cmake-js/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { "node": ">=10" } }, - "node_modules/chrome-remote-interface": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/chrome-remote-interface/-/chrome-remote-interface-0.33.3.tgz", - "integrity": "sha512-zNnn0prUL86Teru6UCAZ1yU1XeXljHl3gj7OrfPcarEfU62OUU4IujDPdTDW3dAWwRqN3ZMG/Chhkh2gPL/wiw==", + "node_modules/cmake-js/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": { - "commander": "2.11.x", - "ws": "^7.2.0" + "ansi-regex": "^5.0.1" }, - "bin": { - "chrome-remote-interface": "bin/client.js" + "engines": { + "node": ">=8" } }, - "node_modules/chrome-remote-interface/node_modules/commander": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", - "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "node_modules/cmake-js/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cmake-js/node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "license": "MIT" }, - "node_modules/chrome-remote-interface/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "node_modules/cmake-js/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "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" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "engines": { + "node": ">=12" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "license": "MIT", + "node_modules/cmake-js/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": ">=6.0" + "node": ">=12" } }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", - "license": "MIT" + "node_modules/cmake-ts": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cmake-ts/-/cmake-ts-0.6.1.tgz", + "integrity": "sha512-uUn2qGhf20j8W/sQ7+UnvvqO1zNccqgbLgwRJi7S23FsjMWJqxvKK80Vc+tvLNKfpJzwH0rgoQD1l24SMnX0yg==", + "license": "MIT", + "bin": { + "cmake-ts": "build/main.js" + } }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "license": "MIT" + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/cli-docs-generator": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cli-docs-generator/-/cli-docs-generator-1.0.7.tgz", - "integrity": "sha512-b0PkG+SNBfonYM+MYqxfxV4W9HYIP9NSZS8Ns5puuLYTgaiiBHHorHeDmLNkkn+atOf6wBxsp4dLPENtPZBPDw==", + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, "license": "MIT", "dependencies": { - "yargs": "^14.2.0" - }, - "bin": { - "cli-docs-generator": "src/cli.js" + "color-name": "1.1.3" } }, - "node_modules/cli-docs-generator/node_modules/ansi-regex": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", - "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colour": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/colour/-/colour-0.7.1.tgz", + "integrity": "sha512-Rel466v0EnmKPcsxHo91L4kgPs/6XF7Pu2LJNszq9lXYwi5CFWEeIiRaTX5ym7PPMdj4udDHkLSVC1//JVkZQg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.8" } }, - "node_modules/cli-docs-generator/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { - "color-convert": "^1.9.0" + "delayed-stream": "~1.0.0" }, "engines": { - "node": ">=4" + "node": ">= 0.8" } }, - "node_modules/cli-docs-generator/node_modules/cliui": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", - "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", - "dev": true, - "license": "ISC", + "node_modules/compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "license": "MIT" + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", "dependencies": { - "string-width": "^3.1.0", - "strip-ansi": "^5.2.0", - "wrap-ansi": "^5.1.0" + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" } }, - "node_modules/cli-docs-generator/node_modules/emoji-regex": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", - "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", - "dev": true, - "license": "MIT" + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } }, - "node_modules/cli-docs-generator/node_modules/find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", - "dev": true, + "node_modules/compression": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", + "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", "license": "MIT", "dependencies": { - "locate-path": "^3.0.0" + "accepts": "~1.3.5", + "bytes": "3.0.0", + "compressible": "~2.0.16", + "debug": "2.6.9", + "on-headers": "~1.0.2", + "safe-buffer": "5.1.2", + "vary": "~1.1.2" }, "engines": { - "node": ">=6" + "node": ">= 0.8.0" } }, - "node_modules/cli-docs-generator/node_modules/is-fullwidth-code-point": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", - "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, + "node_modules/compression/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">= 0.8" } }, - "node_modules/cli-docs-generator/node_modules/locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", - "dev": true, + "node_modules/compression/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": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" - }, - "engines": { - "node": ">=6" + "ms": "2.0.0" } }, - "node_modules/cli-docs-generator/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "node_modules/compression/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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "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": { - "p-try": "^2.0.0" + "safe-buffer": "5.2.1" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/cli-docs-generator/node_modules/p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", - "dev": true, + "node_modules/content-disposition/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/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", - "dependencies": { - "p-limit": "^2.0.0" - }, "engines": { - "node": ">=6" + "node": ">= 0.6" } }, - "node_modules/cli-docs-generator/node_modules/path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { - "node": ">=4" + "node": ">=18" } }, - "node_modules/cli-docs-generator/node_modules/string-width": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", - "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", - "dev": true, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "license": "MIT", "dependencies": { - "emoji-regex": "^7.0.1", - "is-fullwidth-code-point": "^2.0.0", - "strip-ansi": "^5.1.0" + "cookie": "0.7.2", + "cookie-signature": "1.0.6" }, "engines": { - "node": ">=6" + "node": ">= 0.8.0" } }, - "node_modules/cli-docs-generator/node_modules/strip-ansi": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", - "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "dependencies": { - "ansi-regex": "^4.1.0" - }, "engines": { - "node": ">=6" + "node": ">= 0.6" } }, - "node_modules/cli-docs-generator/node_modules/wrap-ansi": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", - "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", - "dev": true, + "node_modules/cookie-session": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.0.0.tgz", + "integrity": "sha512-hKvgoThbw00zQOleSlUr2qpvuNweoqBtxrmx0UFosx6AGi9lYtLoA+RbsvknrEX8Pr6MDbdWAb2j6SnMn+lPsg==", "license": "MIT", "dependencies": { - "ansi-styles": "^3.2.0", - "string-width": "^3.0.0", - "strip-ansi": "^5.0.0" + "cookies": "0.8.0", + "debug": "3.2.7", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1" }, "engines": { - "node": ">=6" + "node": ">= 0.10" } }, - "node_modules/cli-docs-generator/node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/cli-docs-generator/node_modules/yargs": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz", - "integrity": "sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==", - "dev": true, + "node_modules/cookie-session/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "license": "MIT", "dependencies": { - "cliui": "^5.0.0", - "decamelize": "^1.2.0", - "find-up": "^3.0.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^3.0.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^15.0.1" + "ms": "^2.1.1" } }, - "node_modules/cli-docs-generator/node_modules/yargs-parser": { - "version": "15.0.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.3.tgz", - "integrity": "sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==", - "dev": true, - "license": "ISC", + "node_modules/cookie-session/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/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookies": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", + "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "license": "MIT", "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" } }, - "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", + "node_modules/copyfiles": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", + "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "glob": "^7.0.5", + "minimatch": "^3.0.3", + "mkdirp": "^1.0.4", + "noms": "0.0.0", + "through2": "^2.0.1", + "untildify": "^4.0.0", + "yargs": "^16.1.0" }, - "engines": { - "node": ">=12" + "bin": { + "copyfiles": "copyfiles", + "copyup": "copyfiles" } }, - "node_modules/cliui/node_modules/ansi-regex": { + "node_modules/copyfiles/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==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/cliui/node_modules/strip-ansi": { + "node_modules/copyfiles/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/copyfiles/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==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8671,1089 +9705,1068 @@ "node": ">=8" } }, - "node_modules/cmake-js": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-7.3.1.tgz", - "integrity": "sha512-aJtHDrTFl8qovjSSqXT9aC2jdGfmP8JQsPtjdLAXFfH1BF4/ImZ27Jx0R61TFg8Apc3pl6e2yBKMveAeRXx2Rw==", - "license": "MIT", - "dependencies": { - "axios": "^1.6.5", - "debug": "^4", - "fs-extra": "^11.2.0", - "memory-stream": "^1.0.0", - "node-api-headers": "^1.1.0", - "npmlog": "^6.0.2", - "rc": "^1.2.7", - "semver": "^7.5.4", - "tar": "^6.2.0", - "url-join": "^4.0.1", - "which": "^2.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "cmake-js": "bin/cmake-js" - }, - "engines": { - "node": ">= 14.15.0" - } - }, - "node_modules/cmake-js/node_modules/fs-extra": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", - "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "node_modules/copyfiles/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" }, "engines": { - "node": ">=14.14" - } - }, - "node_modules/cmake-js/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "node": ">=10" } }, - "node_modules/cmake-js/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/copyfiles/node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, "engines": { "node": ">=10" } }, - "node_modules/cmake-js/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/core-js": { + "version": "3.44.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz", + "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==", + "hasInstallScript": true, "license": "MIT", - "engines": { - "node": ">= 10.0.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/cmake-js/node_modules/url-join": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", - "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT" }, - "node_modules/cmake-ts": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/cmake-ts/-/cmake-ts-0.6.1.tgz", - "integrity": "sha512-uUn2qGhf20j8W/sQ7+UnvvqO1zNccqgbLgwRJi7S23FsjMWJqxvKK80Vc+tvLNKfpJzwH0rgoQD1l24SMnX0yg==", - "license": "MIT", - "bin": { - "cmake-ts": "build/main.js" - } - }, - "node_modules/code-point-at": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" } }, - "node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "license": "ISC", + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", "bin": { - "color-support": "bin.js" - } - }, - "node_modules/colour": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/colour/-/colour-0.7.1.tgz", - "integrity": "sha512-Rel466v0EnmKPcsxHo91L4kgPs/6XF7Pu2LJNszq9lXYwi5CFWEeIiRaTX5ym7PPMdj4udDHkLSVC1//JVkZQg==", - "license": "MIT", + "crc32": "bin/crc32.njs" + }, "engines": { "node": ">=0.8" } }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 10" } }, - "node_modules/compare-versions": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz", - "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", - "license": "MIT" - }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">= 10" + "node": ">= 8" } }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "node_modules/csrf": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", + "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", "license": "MIT", "dependencies": { - "mime-db": ">= 1.43.0 < 2" + "rndm": "1.2.0", + "tsscmp": "1.0.6", + "uid-safe": "2.1.5" }, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/compression": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.7.4.tgz", - "integrity": "sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==", + "node_modules/csurf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", + "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", + "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", "license": "MIT", "dependencies": { - "accepts": "~1.3.5", - "bytes": "3.0.0", - "compressible": "~2.0.16", - "debug": "2.6.9", - "on-headers": "~1.0.2", - "safe-buffer": "5.1.2", - "vary": "~1.1.2" + "cookie": "0.4.0", + "cookie-signature": "1.0.6", + "csrf": "3.1.0", + "http-errors": "~1.7.3" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/compression/node_modules/bytes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", - "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "node_modules/csurf/node_modules/cookie": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/csurf/node_modules/depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/csurf/node_modules/http-errors": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", + "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/compression/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/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "node_modules/csurf/node_modules/setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", "license": "ISC" }, - "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==", + "node_modules/csurf/node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { "node": ">= 0.6" } }, - "node_modules/content-disposition/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/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/csurf/node_modules/toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.6" } }, - "node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "node_modules/d3": { + "version": "3.5.17", + "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", + "integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==", + "license": "BSD-3-Clause" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, "engines": { - "node": ">=18" + "node": ">=0.10" } }, - "node_modules/cookie-parser": { - "version": "1.4.7", - "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", - "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "license": "MIT", "dependencies": { - "cookie": "0.7.2", - "cookie-signature": "1.0.6" + "ms": "2.1.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/cookie-parser/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=0.10.0" } }, - "node_modules/cookie-session": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.0.0.tgz", - "integrity": "sha512-hKvgoThbw00zQOleSlUr2qpvuNweoqBtxrmx0UFosx6AGi9lYtLoA+RbsvknrEX8Pr6MDbdWAb2j6SnMn+lPsg==", + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", "license": "MIT", - "dependencies": { - "cookies": "0.8.0", - "debug": "3.2.7", - "on-headers": "~1.0.2", - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.10" + "node": ">=0.10" } }, - "node_modules/cookie-session/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "engines": { + "node": ">=4.0.0" } }, - "node_modules/cookie-session/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" - } - ], + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, "license": "MIT" }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, - "node_modules/cookies": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.8.0.tgz", - "integrity": "sha512-8aPsApQfebXnuI+537McwYsDtjVxGm8gTIzQI3FDW6t5t/DAhERxtnbEPN/8RX+uZthoz4eCOgloXaE5cYyNow==", + "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", - "dependencies": { - "depd": "~2.0.0", - "keygrip": "~1.1.0" - }, "engines": { "node": ">= 0.8" } }, - "node_modules/copyfiles": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz", - "integrity": "sha512-fereAvAvxDrQDOXybk3Qu3dPbOoKoysFMWtkY3mv5BsL8//OSZVL5DCLYqgRfY5cWirgRzlC+WSrxp6Bo3eNZg==", - "dev": true, + "node_modules/desandro-classie": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/desandro-classie/-/desandro-classie-1.0.1.tgz", + "integrity": "sha512-2kLS4P8Qjt2Stq4/rKfFnU+Mn7MipU8VfnC5UMx51nmLFux1n93dcWpcalpA8vPsIJJbaatbRmDCGdnBRzTk4g==", + "license": "MIT" + }, + "node_modules/desandro-get-style-property": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/desandro-get-style-property/-/desandro-get-style-property-1.0.4.tgz", + "integrity": "sha512-5CIubfvFTYTYkHif1XhYuEZIfJ6SZKyLA+/SbO4pdxX50Cz+Ft8OJpThtiK1JRU9i4+OSDuLNxLlk3EGSb8Rhg==", + "license": "MIT" + }, + "node_modules/desandro-matches-selector": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/desandro-matches-selector/-/desandro-matches-selector-1.0.3.tgz", + "integrity": "sha512-VUZQvKZJSoN6kwEHos41FSiK8Tm9b73+UN9/6hy4926qlSTXwK/SdXD9KZck7Twiy/n29YLmdxD5LdVwEzjuFg==", + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "license": "MIT", - "dependencies": { - "glob": "^7.0.5", - "minimatch": "^3.0.3", - "mkdirp": "^1.0.4", - "noms": "0.0.0", - "through2": "^2.0.1", - "untildify": "^4.0.0", - "yargs": "^16.1.0" - }, - "bin": { - "copyfiles": "copyfiles", - "copyup": "copyfiles" + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/copyfiles/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==", - "dev": true, - "license": "MIT", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", "engines": { "node": ">=8" } }, - "node_modules/copyfiles/node_modules/cliui": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", - "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^7.0.0" - } + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true }, - "node_modules/copyfiles/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==", - "dev": true, + "node_modules/devtools-protocol": { + "version": "0.0.927104", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.927104.tgz", + "integrity": "sha512-5jfffjSuTOv0Lz53wTNNTcCUV8rv7d82AhYcapj28bC2B5tDxEZzVb7k51cNxZP2KHw24QE+sW7ZuSeD9NfMpA==", + "license": "BSD-3-Clause" + }, + "node_modules/difunc": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/difunc/-/difunc-0.0.4.tgz", + "integrity": "sha512-zBiL4ALDmviHdoLC0g0G6wVme5bwAow9WfhcZLLopXCAWgg3AEf7RYTs2xugszIGulRHzEVDF/SHl9oyQU07Pw==", "license": "MIT", "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" + "esprima": "^4.0.0" } }, - "node_modules/copyfiles/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", - "dev": true, + "node_modules/doc-ready": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/doc-ready/-/doc-ready-1.0.4.tgz", + "integrity": "sha512-o1L4nBJ32nqTqTiTPntf2XNMLCQ9zZudmL1xRWfGoH4WOyEHTKxxpKc7c93Kb1k8yuLbdj2EGz7A47k3f2NroQ==", "license": "MIT", "dependencies": { - "cliui": "^7.0.2", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" - }, - "engines": { - "node": ">=10" + "eventie": "^1" } }, - "node_modules/copyfiles/node_modules/yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" }, - "node_modules/core-js": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz", - "integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==", - "hasInstallScript": true, + "node_modules/draggabilly": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/draggabilly/-/draggabilly-1.2.4.tgz", + "integrity": "sha512-qixTSr01vSgmv9e7F0qvLXvG9namqDb6PWiodTEhcjvtfFSbq+J0YmzxhKu9kTORU/BmgtBBbol/b3kmQt2Tfw==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "dependencies": { + "desandro-classie": "~1.0.1", + "desandro-get-style-property": "~1.0.4", + "get-size": "~1.2.2", + "unidragger": "~1.1.0" } }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", + "node_modules/dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "hasInstallScript": true, + "license": "BSD-2-Clause", + "optional": true, "dependencies": { - "object-assign": "^4", - "vary": "^1" + "nan": "^2.14.0" }, "engines": { - "node": ">= 0.10" + "node": ">=0.10" } }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "license": "Apache-2.0", - "bin": { - "crc32": "bin/crc32.njs" + "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.8" + "node": ">= 0.4" } }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "license": "MIT", "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", + "node_modules/ecc-jsbn/node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" + "safe-buffer": "^5.0.1" } }, - "node_modules/csrf": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/csrf/-/csrf-3.1.0.tgz", - "integrity": "sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==", + "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/electron-to-chromium": { + "version": "1.5.193", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.193.tgz", + "integrity": "sha512-eePuBZXM9OVCwfYUhd2OzESeNGnWmLyeu0XAEjf7xjijNjHFdeJSzuRUGN4ueT2tEYo5YqjHramKEFxz67p3XA==", + "license": "ISC" + }, + "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/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", - "dependencies": { - "rndm": "1.2.0", - "tsscmp": "1.0.6", - "uid-safe": "2.1.5" - }, "engines": { "node": ">= 0.8" } }, - "node_modules/csurf": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/csurf/-/csurf-1.11.0.tgz", - "integrity": "sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ==", - "deprecated": "This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions", + "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": { - "cookie": "0.4.0", - "cookie-signature": "1.0.6", - "csrf": "3.1.0", - "http-errors": "~1.7.3" - }, - "engines": { - "node": ">= 0.8.0" + "once": "^1.4.0" } }, - "node_modules/csurf/node_modules/cookie": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", - "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", + "node_modules/engine.io": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", "license": "MIT", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, "engines": { - "node": ">= 0.6" + "node": ">=10.2.0" } }, - "node_modules/csurf/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=10.0.0" } }, - "node_modules/csurf/node_modules/http-errors": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.3.tgz", - "integrity": "sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==", + "node_modules/engine.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.1.1", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.0" - }, "engines": { "node": ">= 0.6" } }, - "node_modules/csurf/node_modules/setprototypeof": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", - "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==", - "license": "ISC" - }, - "node_modules/csurf/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "node_modules/engine.io/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "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/csurf/node_modules/toidentifier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", - "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==", + "node_modules/enhanced-resolve": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", + "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, "engines": { - "node": ">=0.6" + "node": ">=10.13.0" } }, - "node_modules/d3": { - "version": "3.5.17", - "resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz", - "integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==", - "license": "BSD-3-Clause" - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "node_modules/env-paths": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", + "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", "license": "MIT", "dependencies": { - "assert-plus": "^1.0.0" + "is-safe-filename": "^0.1.0" }, "engines": { - "node": ">=0.10" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/epoch-charting": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/epoch-charting/-/epoch-charting-0.8.4.tgz", + "integrity": "sha512-UjUOyNEa0NlJlK+1VBMDbLXLRO36XFa8jtuy9JFLBIQ364yHA8sCDSDrWmogcqKED18VfKZcUk2DCoJrkMXVFw==", "license": "MIT", "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "d3": "^3.4.13" } }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", - "engines": { - "node": ">=0.10.0" + "dependencies": { + "is-arrayish": "^0.2.1" } }, - "node_modules/decode-uri-component": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", - "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "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.10" + "node": ">= 0.4" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "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": ">=4.0.0" + "node": ">= 0.4" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "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.0" + "node": ">= 0.4" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" - }, - "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==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" } }, - "node_modules/desandro-classie": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/desandro-classie/-/desandro-classie-1.0.1.tgz", - "integrity": "sha512-2kLS4P8Qjt2Stq4/rKfFnU+Mn7MipU8VfnC5UMx51nmLFux1n93dcWpcalpA8vPsIJJbaatbRmDCGdnBRzTk4g==", - "license": "MIT" - }, - "node_modules/desandro-get-style-property": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/desandro-get-style-property/-/desandro-get-style-property-1.0.4.tgz", - "integrity": "sha512-5CIubfvFTYTYkHif1XhYuEZIfJ6SZKyLA+/SbO4pdxX50Cz+Ft8OJpThtiK1JRU9i4+OSDuLNxLlk3EGSb8Rhg==", - "license": "MIT" - }, - "node_modules/desandro-matches-selector": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/desandro-matches-selector/-/desandro-matches-selector-1.0.3.tgz", - "integrity": "sha512-VUZQvKZJSoN6kwEHos41FSiK8Tm9b73+UN9/6hy4926qlSTXwK/SdXD9KZck7Twiy/n29YLmdxD5LdVwEzjuFg==", - "license": "MIT" - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/esbuild": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "node_modules/esbuild/node_modules/@esbuild/android-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", + "cpu": [ + "arm" + ], "license": "MIT", - "optional": true - }, - "node_modules/devtools-protocol": { - "version": "0.0.927104", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.927104.tgz", - "integrity": "sha512-5jfffjSuTOv0Lz53wTNNTcCUV8rv7d82AhYcapj28bC2B5tDxEZzVb7k51cNxZP2KHw24QE+sW7ZuSeD9NfMpA==", - "license": "BSD-3-Clause" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/difunc": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/difunc/-/difunc-0.0.4.tgz", - "integrity": "sha512-zBiL4ALDmviHdoLC0g0G6wVme5bwAow9WfhcZLLopXCAWgg3AEf7RYTs2xugszIGulRHzEVDF/SHl9oyQU07Pw==", + "node_modules/esbuild/node_modules/@esbuild/android-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "esprima": "^4.0.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/doc-ready": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/doc-ready/-/doc-ready-1.0.4.tgz", - "integrity": "sha512-o1L4nBJ32nqTqTiTPntf2XNMLCQ9zZudmL1xRWfGoH4WOyEHTKxxpKc7c93Kb1k8yuLbdj2EGz7A47k3f2NroQ==", + "node_modules/esbuild/node_modules/@esbuild/android-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "eventie": "^1" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/dom-walk": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz", - "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" - }, - "node_modules/draggabilly": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/draggabilly/-/draggabilly-1.2.4.tgz", - "integrity": "sha512-qixTSr01vSgmv9e7F0qvLXvG9namqDb6PWiodTEhcjvtfFSbq+J0YmzxhKu9kTORU/BmgtBBbol/b3kmQt2Tfw==", + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "desandro-classie": "~1.0.1", - "desandro-get-style-property": "~1.0.4", - "get-size": "~1.2.2", - "unidragger": "~1.1.0" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" } }, - "node_modules/dtrace-provider": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", - "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", - "hasInstallScript": true, - "license": "BSD-2-Clause", + "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", + "cpu": [ + "arm64" + ], + "license": "MIT", "optional": true, - "dependencies": { - "nan": "^2.14.0" - }, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.10" + "node": ">=18" } }, - "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==", + "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "node_modules/esbuild/node_modules/@esbuild/linux-arm": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", + "cpu": [ + "arm" + ], "license": "MIT", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/ecc-jsbn/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" + "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "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/electron-to-chromium": { - "version": "1.5.193", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.193.tgz", - "integrity": "sha512-eePuBZXM9OVCwfYUhd2OzESeNGnWmLyeu0XAEjf7xjijNjHFdeJSzuRUGN4ueT2tEYo5YqjHramKEFxz67p3XA==", - "license": "ISC" - }, - "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/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", + "cpu": [ + "ia32" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "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==", + "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", + "cpu": [ + "loong64" + ], "license": "MIT", - "dependencies": { - "once": "^1.4.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/engine.io": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", - "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", + "cpu": [ + "mips64el" + ], "license": "MIT", - "dependencies": { - "@types/cookie": "^0.4.1", - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.4.1", - "cors": "~2.8.5", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.2.0" + "node": ">=18" } }, - "node_modules/engine.io-parser": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", - "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", + "cpu": [ + "riscv64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.0.0" + "node": ">=18" } }, - "node_modules/engine.io/node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", + "cpu": [ + "s390x" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.6" + "node": ">=18" } }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "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": ">=18" } }, - "node_modules/enhanced-resolve": { - "version": "5.18.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", - "integrity": "sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==", + "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=10.13.0" + "node": ">=18" } }, - "node_modules/env-paths": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", - "integrity": "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw==", + "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "is-safe-filename": "^0.1.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/epoch-charting": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/epoch-charting/-/epoch-charting-0.8.4.tgz", - "integrity": "sha512-UjUOyNEa0NlJlK+1VBMDbLXLRO36XFa8jtuy9JFLBIQ364yHA8sCDSDrWmogcqKED18VfKZcUk2DCoJrkMXVFw==", + "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "d3": "^3.4.13" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", + "cpu": [ + "x64" + ], "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, - "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==", + "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "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==", + "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "license": "MIT" - }, - "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==", + "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", + "cpu": [ + "arm64" + ], "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", + "cpu": [ + "ia32" + ], "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/esbuild": { + "node_modules/esbuild/node_modules/@esbuild/win32-x64": { "version": "0.25.8", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", - "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", - "hasInstallScript": true, + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", + "cpu": [ + "x64" + ], "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.8", - "@esbuild/android-arm": "0.25.8", - "@esbuild/android-arm64": "0.25.8", - "@esbuild/android-x64": "0.25.8", - "@esbuild/darwin-arm64": "0.25.8", - "@esbuild/darwin-x64": "0.25.8", - "@esbuild/freebsd-arm64": "0.25.8", - "@esbuild/freebsd-x64": "0.25.8", - "@esbuild/linux-arm": "0.25.8", - "@esbuild/linux-arm64": "0.25.8", - "@esbuild/linux-ia32": "0.25.8", - "@esbuild/linux-loong64": "0.25.8", - "@esbuild/linux-mips64el": "0.25.8", - "@esbuild/linux-ppc64": "0.25.8", - "@esbuild/linux-riscv64": "0.25.8", - "@esbuild/linux-s390x": "0.25.8", - "@esbuild/linux-x64": "0.25.8", - "@esbuild/netbsd-arm64": "0.25.8", - "@esbuild/netbsd-x64": "0.25.8", - "@esbuild/openbsd-arm64": "0.25.8", - "@esbuild/openbsd-x64": "0.25.8", - "@esbuild/openharmony-arm64": "0.25.8", - "@esbuild/sunos-x64": "0.25.8", - "@esbuild/win32-arm64": "0.25.8", - "@esbuild/win32-ia32": "0.25.8", - "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -9995,10 +11008,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -10028,6 +11040,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -10107,6 +11129,16 @@ "resolved": "https://registry.npmjs.org/exif-parser/-/exif-parser-0.1.12.tgz", "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exports-loader": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/exports-loader/-/exports-loader-4.0.0.tgz", @@ -10123,18 +11155,8 @@ "type": "opencollective", "url": "https://opencollective.com/webpack" }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/exports-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" + "peerDependencies": { + "webpack": "^5.0.0" } }, "node_modules/express": { @@ -10697,12 +11719,6 @@ "node": ">= 0.6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -10779,6 +11795,20 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/ftp-response-parser": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ftp-response-parser/-/ftp-response-parser-1.0.1.tgz", @@ -10873,6 +11903,18 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -11499,15 +12541,18 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.14.2.tgz", - "integrity": "sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz", + "integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==", "license": "Apache-2.0", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" } }, "node_modules/imports-loader": { @@ -11531,16 +12576,6 @@ "webpack": "^5.0.0" } }, - "node_modules/imports-loader/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -11635,21 +12670,6 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-dir": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-dir/-/is-dir-1.0.0.tgz", @@ -12143,6 +13163,267 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -12271,6 +13552,15 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/map-stream": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.0.7.tgz", @@ -12401,6 +13691,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/method-override": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/method-override/-/method-override-3.0.0.tgz", @@ -12800,6 +14099,25 @@ "license": "MIT", "optional": true }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -12912,14 +14230,37 @@ "hasInstallScript": true, "license": "(MIT OR X11)", "dependencies": { - "node-addon-api": "^3.2.1", - "pkg-prebuilds": "^1.0.0" - }, - "bin": { - "hid-showdevices": "src/show-devices.js" + "node-addon-api": "^3.2.1", + "pkg-prebuilds": "^1.0.0" + }, + "bin": { + "hid-showdevices": "src/show-devices.js" + }, + "engines": { + "node": ">=10.16" + } + }, + "node_modules/node-hid/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/node-hid/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": ">=10.16" + "node": ">=12" } }, "node_modules/node-hid/node_modules/node-addon-api": { @@ -12944,6 +14285,45 @@ "node": ">= 14.15.0" } }, + "node_modules/node-hid/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/node-hid/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "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/node-hid/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/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -13185,6 +14565,20 @@ "license": "MIT", "optional": true }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oidc-token-hash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", @@ -13737,12 +15131,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -13774,6 +15162,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -13817,37 +15212,6 @@ "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", "license": "MIT" }, - "node_modules/pg-int8": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", - "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", - "license": "ISC", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", - "license": "MIT" - }, - "node_modules/pg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", - "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", - "license": "MIT", - "dependencies": { - "pg-int8": "1.0.1", - "postgres-array": "~2.0.0", - "postgres-bytea": "~1.0.0", - "postgres-date": "~1.0.4", - "postgres-interval": "^1.1.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/phin": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/phin/-/phin-3.7.1.tgz", @@ -13928,6 +15292,68 @@ "node": ">= 14.15.0" } }, + "node_modules/pkg-prebuilds/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/pkg-prebuilds/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/pkg-prebuilds/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/pkg-prebuilds/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "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/pkg-prebuilds/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/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -13969,43 +15395,33 @@ "node": ">=12.13.0" } }, - "node_modules/postgres-array": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", - "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postgres-bytea": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", - "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-date": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", - "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postgres-interval": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", - "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "xtend": "^4.0.0" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=0.10.0" + "node": "^10 || ^12 || >=14" } }, "node_modules/postman-request": { @@ -14701,23 +16117,22 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.5.2", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", - "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", "license": "MIT", "dependencies": { "debug": "^4.3.5", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.8" + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/require-in-the-middle/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "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" @@ -14750,26 +16165,6 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "license": "MIT" }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -14904,6 +16299,40 @@ "integrity": "sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==", "license": "MIT" }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -15071,6 +16500,12 @@ "license": "MIT", "optional": true }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, "node_modules/semver": { "version": "7.5.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", @@ -15430,12 +16865,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shimmer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", - "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", - "license": "BSD-2-Clause" - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -15508,6 +16937,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -15632,6 +17068,25 @@ "node": ">= 14" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -15642,15 +17097,6 @@ "source-map": "^0.6.0" } }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -15782,6 +17228,13 @@ "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -15791,6 +17244,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stealthy-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", @@ -16038,21 +17498,9 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "license": "MIT", + "engines": { + "node": ">=0.8.0" } }, "node_modules/tagged-tag": { @@ -16295,12 +17743,87 @@ "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", @@ -16520,20 +18043,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -16810,18 +18319,18 @@ } }, "node_modules/usb-hotplug": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/usb-hotplug/-/usb-hotplug-0.1.6.tgz", - "integrity": "sha512-fgM12UBGIclItx+SmX+AlxSS+vO4KUGNvshdGXMSyN/gXWDnjLkYdj9tpyypKd7bvHz4HJRbqQEr9zQ/ommjXg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/usb-hotplug/-/usb-hotplug-1.0.0.tgz", + "integrity": "sha512-249RCj4oIfCgexFZ2qFIcPMi9MEomOzSRz7etofdiOH833VZDePj2q0K8UXUi8G4WcoXqbEHYLdivJmy+qtofQ==", "license": "MIT", "engines": { "node": ">=16" }, "optionalDependencies": { - "@e-khalilov/usb-hotplug-darwin-arm64": "0.1.6", - "@e-khalilov/usb-hotplug-darwin-x64": "0.1.6", - "@e-khalilov/usb-hotplug-linux-x64-gnu": "0.1.6", - "@e-khalilov/usb-hotplug-win32-x64-msvc": "0.1.6" + "@e-khalilov/usb-hotplug-darwin-arm64": "1.0.0", + "@e-khalilov/usb-hotplug-darwin-x64": "1.0.0", + "@e-khalilov/usb-hotplug-linux-x64-gnu": "1.0.0", + "@e-khalilov/usb-hotplug-win32-x64-msvc": "1.0.0" } }, "node_modules/utf-8-validate": { @@ -16955,6 +18464,283 @@ "node": ">=0.6.0" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/watchpack": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", @@ -17116,6 +18902,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -17376,30 +19179,79 @@ } }, "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", "license": "MIT", "dependencies": { - "cliui": "^8.0.1", + "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", + "string-width": "^7.2.0", "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "yargs-parser": "^22.0.0" }, "engines": { - "node": ">=12" + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "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==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/yauzl": { diff --git a/package.json b/package.json index 6369834519..c52700fa40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vk-devicehub", - "version": "1.5.2", + "version": "1.5.3", "description": "Smartphone Test Farm", "type": "module", "keywords": [ @@ -22,6 +22,8 @@ "scripts": { "doc": "cli-docs-generator --cli=stf --pretty_cli_name=stf -o ./units.md", "lint": "eslint ./lib", + "test:unit": "vitest run --config ./vitest.config.ts", + "test:unit:watch": "vitest --config ./vitest.config.ts", "typecheck": "tsc -p tsconfig.typecheck.json", "build:swagger:tests": "cd ./test/api && poetry run just regen-schema", "build:swagger:ui": "cd ./ui && npm run generate-api", @@ -52,7 +54,7 @@ "@julusian/jpeg-turbo": "2.1.0", "@node-saml/passport-saml": "5.1.0", "@protobuf-ts/plugin": "^2.11.1", - "@sentry/node": "^8.34.0", + "@sentry/node": "^10.62.0", "@types/chrome-remote-interface": "^0.31.14", "@types/cookie-parser": "^1.4.10", "@u4/adbkit": "^5.1.7", @@ -131,13 +133,13 @@ "tsx": "4.20.3", "underscore.string": "3.3.6", "url-join": "1.1.0", - "usb-hotplug": "^0.1.6", + "usb-hotplug": "^1.0.0", "utf-8-validate": "5.0.9", "uuid": "^11.0.3", "webpack": "^5.88.1", "ws": "5.2.4", "yaml": "2.4.2", - "yargs": "^17.7.2", + "yargs": "^18.0.0", "yauzl": "2.10.0", "zeromq": "6.4.2" }, @@ -170,7 +172,8 @@ "http-https": "1.0.0", "imports-loader": "^4.0.1", "typescript": "^5.9.2", - "typescript-eslint": "^8.39.1" + "typescript-eslint": "^8.39.1", + "vitest": "^4.1.10" }, "overrides": { "busboy": "npm:@fastify/busboy@1.2.1", diff --git a/test/unit/units/api/useDevice.test.ts b/test/unit/units/api/useDevice.test.ts new file mode 100644 index 0000000000..613eb0f2dc --- /dev/null +++ b/test/unit/units/api/useDevice.test.ts @@ -0,0 +1,87 @@ +import {describe, it, expect, vi} from 'vitest' +import {useDevice, UseDeviceError} from '../../../../lib/units/api/helpers/useDevice.ts' +import {GroupMessage, ConnectStartMessage} from '../../../../lib/wire/wire.ts' + +// Pure acquisition logic, decoupled from express/dbapi/ZMQ. It takes an already +// loaded device, the requesting user and a `runTransaction` closure (bound to a +// single AppTransport/TransactionManager) and drives the two-step take-control +// flow: join the device's group, then start the remote connection. It returns +// the connect url or rejects with a UseDeviceError code. + +const addableDevice = (over: object = {}) => ({ + serial: 'serial-1', + present: true, + ready: true, + using: false, + owner: null, + provider: {name: 'provider-a'}, + ...over, +}) + +const user = {email: 'u@e.com', name: 'u', group: 'g1', adbKeys: [{fingerprint: 'fp1'}]} + +describe('useDevice', () => { + it('joins the group then starts connect, resolving with the connect url', async () => { + const device = addableDevice() + const runTransaction = vi.fn() + // GroupMessage -> join confirmed + .mockResolvedValueOnce({success: true, data: 'success', body: {}}) + // ConnectStartMessage -> url carried in `data` + .mockResolvedValueOnce({success: true, data: 'ws://device:1234', body: {}}) + + const url = await useDevice({device, user, usage: 'automation', runTransaction}) + + expect(url).toBe('ws://device:1234') + expect(runTransaction).toHaveBeenCalledTimes(2) + expect(runTransaction.mock.calls[0][0]).toBe(GroupMessage) + expect(runTransaction.mock.calls[1][0]).toBe(ConnectStartMessage) + }) + + it('rejects NOT_FOUND when no device is given', async () => { + const runTransaction = vi.fn() + await expect(useDevice({device: null, user, runTransaction})) + .rejects.toBe(UseDeviceError.NOT_FOUND) + expect(runTransaction).not.toHaveBeenCalled() + }) + + it('rejects ALREADY_IN_USE when the device is not addable', async () => { + const device = addableDevice({using: true, owner: {email: 'other@e.com'}}) + const runTransaction = vi.fn() + await expect(useDevice({device, user, runTransaction})) + .rejects.toBe(UseDeviceError.ALREADY_IN_USE) + expect(runTransaction).not.toHaveBeenCalled() + }) + + it('rejects FAILED_JOIN when the group transaction rejects', async () => { + const device = addableDevice() + const runTransaction = vi.fn().mockRejectedValueOnce(new Error('timeout')) + await expect(useDevice({device, user, runTransaction})) + .rejects.toBe(UseDeviceError.FAILED_JOIN) + expect(runTransaction).toHaveBeenCalledTimes(1) + }) + + it('rejects FAILED_CONNECT when the connect transaction rejects', async () => { + const device = addableDevice() + const runTransaction = vi.fn() + .mockResolvedValueOnce({success: true, data: 'success', body: {}}) + .mockRejectedValueOnce(new Error('timeout')) + await expect(useDevice({device, user, runTransaction})) + .rejects.toBe(UseDeviceError.FAILED_CONNECT) + expect(runTransaction).toHaveBeenCalledTimes(2) + }) + + it('passes the device serial requirement and owner to the group transaction', async () => { + const device = addableDevice() + const runTransaction = vi.fn() + .mockResolvedValueOnce({success: true, data: 'success', body: {}}) + .mockResolvedValueOnce({success: true, data: 'url', body: {}}) + + await useDevice({device, user, usage: 'automation', timeout: 5000, runTransaction}) + + const groupMsg = runTransaction.mock.calls[0][1] + expect(groupMsg.owner.email).toBe('u@e.com') + expect(groupMsg.usage).toBe('automation') + expect(groupMsg.timeout).toBe(5000) + expect(groupMsg.keys).toEqual(['fp1']) + }) +}) diff --git a/test/unit/units/base-device/support/dealer.integration.test.ts b/test/unit/units/base-device/support/dealer.integration.test.ts new file mode 100644 index 0000000000..6e57b16af6 --- /dev/null +++ b/test/unit/units/base-device/support/dealer.integration.test.ts @@ -0,0 +1,149 @@ +import {describe, it, expect, afterEach} from 'vitest' +import {RouterSocket, DealerSocket} from '../../../../../lib/util/zmqsocket.ts' +import {DeviceTransport} from '../../../../../lib/wire/device-transport.ts' +import {deviceKey, KIND} from '../../../../../lib/wire/frame.ts' +import {Envelope} from '../../../../../lib/wire/wire.ts' +import {DeviceHeartbeatMessage, TransactionDoneMessage} from '../../../../../lib/wire/wire.ts' +import wireutil from '../../../../../lib/wire/util.ts' + +// Integration-style test over real in-process TCP sockets: the device-side +// DeviceDealer (over a real DEALER) talking to a ROUTER that stands in for the +// processor. Drives the real public interface — no ZMQ mocking. + +const nextPort = (() => { + let p = 15900 + return () => p++ +})() + +const nextFrames = (sock: {once: (e: string, fn: (f: Buffer[]) => void) => void}): Promise => + new Promise((resolve) => sock.once('frames', resolve)) + +// A ROUTER receiving from a probeRouter=true DEALER sees an initial empty probe +// message ([identity]) before the first real one. Skip probe/empty frames. +const nextRealFrames = (sock: {on: (e: string, fn: (f: Buffer[]) => void) => void; removeListener: (e: string, fn: (f: Buffer[]) => void) => void}): Promise => + new Promise((resolve) => { + const onFrames = (frames: Buffer[]) => { + // [identity] alone, or [identity, emptyBody] is a probe/keepalive. + if (frames.length <= 1 || (frames.length === 2 && frames[1].length === 0)) { + return + } + sock.removeListener('frames', onFrames) + resolve(frames) + } + sock.on('frames', onFrames) + }) + +describe('DeviceDealer <-> processor ROUTER', () => { + const open: Array<{close: () => any}> = [] + afterEach(async () => { + await Promise.all(open.splice(0).map(s => Promise.resolve(s.close()).catch(() => {}))) + }) + + const setup = async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({ + routingId: deviceKey('provider-a', 'serial-1'), + probeRouter: true, + }) + dealer.connect(addr) + const support = new DeviceTransport(dealer) + open.push(support) + return {router, support} + } + + it('sends a device event up as [E, envelope] carrying the device identity', async () => { + const {router, support} = await setup() + + const received = nextRealFrames(router) + support.send([wireutil.global, Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial: 'serial-1'}))]) + + const frames = await received + // ROUTER prepends the device identity (deviceKey). + expect(frames[0].toString()).toBe(deviceKey('provider-a', 'serial-1')) + // A device only ever speaks to its processor: everything that is not a + // reply travels up as a single "E" event. + expect(frames[1].toString()).toBe(KIND.EVENT) + // The body is the heartbeat envelope (no selector frame for E). + const env = Envelope.fromBinary(frames[frames.length - 1]) + expect(env.message?.typeUrl).toContain('DeviceHeartbeatMessage') + }) + + it('receives a processor-originated message with an EMPTY reply-path (registration handshake)', async () => { + // Regression: the processor used to answer with a bare [identity, + // envelope] (no KIND frame), which the device silently dropped — so + // registration only ever completed via a 15s timeout. A + // processor-originated message must arrive as [D, prov, serial, env] + // with an empty reply-path and be delivered to the plugin layer. + const {router, support} = await setup() + + const learn = nextRealFrames(router) + support.send([wireutil.global, Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial: 'serial-1'}))]) + const deviceId = (await learn)[0] + + const gotMessage = new Promise((resolve) => { + support.once('message', (_channel: string, envelope: Buffer) => resolve(envelope)) + }) + + // Processor replies down the SAME way it frames a forwarded command, + // but with NO reply-path: [deviceId, D, prov, serial, envelope]. + const registered = Buffer.from(wireutil.pack(TransactionDoneMessage, {source: 'x', seq: 0, success: true, data: 'ok'})) + await router.send([ + deviceId, + Buffer.from(KIND.DEVICE), + Buffer.from('provider-a'), + Buffer.from('serial-1'), + registered, + ]) + + const env = Envelope.fromBinary(await gotMessage) + expect(env.message?.typeUrl).toContain('TransactionDoneMessage') + }) + + it('receives a device-directed command and replies R with the same reply-path', async () => { + const {router, support} = await setup() + + // Let the ROUTER learn the device identity first (probeRouter may already + // have, but a real send is deterministic). + const learn = nextRealFrames(router) + support.send([wireutil.global, Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial: 'serial-1'}))]) + const deviceId = (await learn)[0] + + // The plugin layer listens for the inbound command's (channel, envelope). + const correlationId = 'txn_integration' + const gotCommand = new Promise<{channel: string; envelope: Buffer}>((resolve) => { + support.once('message', (channel: string, envelope: Buffer) => + resolve({channel, envelope})) + }) + + // Processor sends a command down: [deviceId, D, prov, serial, ...replyPath, env] + const commandEnv = Buffer.from(wireutil.tr(correlationId, DeviceHeartbeatMessage, {serial: 'serial-1'})) + await router.send([ + deviceId, + Buffer.from(KIND.DEVICE), + Buffer.from('provider-a'), + Buffer.from('serial-1'), + Buffer.from('api-1'), // reply-path + commandEnv, + ]) + + const cmd = await gotCommand + expect(cmd.channel).toBe(correlationId) + + // The plugin replies on that channel. + const reply = nextRealFrames(router) + support.send([correlationId, Buffer.from(wireutil.reply('serial-1').okay('success'))]) + + const frames = await reply + expect(frames[0].toString()).toBe(deviceKey('provider-a', 'serial-1')) // ROUTER identity + expect(frames[1].toString()).toBe(KIND.REPLY) + expect(frames[2].toString()).toBe('api-1') // reply-path preserved + // The reply envelope carries the correlationId so the api can match it. + const env = Envelope.fromBinary(frames[frames.length - 1]) + expect(env.channel).toBe(correlationId) + expect(env.message?.typeUrl).toContain('TransactionDoneMessage') + }) +}) diff --git a/test/unit/units/base-device/support/dealer.test.ts b/test/unit/units/base-device/support/dealer.test.ts new file mode 100644 index 0000000000..c5f852f095 --- /dev/null +++ b/test/unit/units/base-device/support/dealer.test.ts @@ -0,0 +1,137 @@ +import {describe, it, expect} from 'vitest' +import {DeviceWire} from '../../../../../lib/wire/device-transport.ts' +import {KIND} from '../../../../../lib/wire/frame.ts' +import {Envelope} from '../../../../../lib/wire/wire.ts' +import {Any} from '../../../../../lib/wire/google/protobuf/any.ts' +import {DeviceHeartbeatMessage, TransactionDoneMessage} from '../../../../../lib/wire/wire.ts' +import wireutil from '../../../../../lib/wire/util.ts' + +// Pure device-side wire logic (no ZMQ, no real timers). DeviceWire turns the +// legacy `send([channel, envelope])` call — which every device plugin still +// makes — into the correct ROUTER/DEALER frame, and remembers the reply-path of +// each inbound command so a reply can be routed back. +// +// channel semantics (a device only ever speaks to its processor): +// known correlationId -> R reply with correlationId re-injected into Envelope.channel +// anything else -> E event; processor decides whether to consume or forward + +const str = (frames: Buffer[]) => frames.map(b => b.toString()) + +// Decode the channel out of an Envelope frame. +const channelOf = (frame: Buffer): string | undefined => + Envelope.fromBinary(frame).channel + +// A wire message an app-directed command would carry. +const heartbeatEnvelope = (serial: string, channel?: string): Buffer => + Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial}, channel)) + +// A reply envelope as produced by wireutil.reply(...).okay() — note it carries +// NO channel of its own. +const replyEnvelope = (): Buffer => + Buffer.from(wireutil.reply('serial-1').okay('success')) + +describe('DeviceWire: outbound send([channel, envelope])', () => { + it('wraps a global-channel send as an event to the processor (E)', () => { + const wire = new DeviceWire() + const env = heartbeatEnvelope('serial-1') + + const out = wire.send(wireutil.global, env)! + + expect(out[0].toString()).toBe(KIND.EVENT) + expect(out[1]).toEqual(env) + expect(out).toHaveLength(2) + }) + + it('wraps an unknown non-global channel (e.g. a group id) as an event too', () => { + const wire = new DeviceWire() + const env = heartbeatEnvelope('serial-1') + + const out = wire.send('group-42', env)! + + expect(out[0].toString()).toBe(KIND.EVENT) + expect(out[1]).toEqual(env) + expect(out).toHaveLength(2) + }) +}) + +describe('DeviceWire: reply round-trip (the tracer bullet)', () => { + it('replies to an inbound command with R + the same reply-path, re-injecting the correlationId', () => { + const wire = new DeviceWire() + + // Inbound command: [D, providerName, serial, ...replyPath, envelope] + // Envelope.channel carries the correlationId. + const correlationId = 'txn_abc' + const command = [ + Buffer.from(KIND.DEVICE), + Buffer.from('provider-a'), + Buffer.from('serial-1'), + Buffer.from('api-1'), // reply-path (api identity pushed by proxy) + heartbeatEnvelope('serial-1', correlationId), + ] + + // Support layer records the reply-path keyed by correlationId and + // hands the plugin (channel, envelope) as before. + const inbound = wire.receiveCommand(command) + expect(inbound.channel).toBe(correlationId) + + // Plugin replies: push.send([correlationId, reply.okay()]). + const out = wire.send(correlationId, replyEnvelope())! + + // -> [R, ...replyPath, envelope] + expect(out[0].toString()).toBe(KIND.REPLY) + expect(out[1].toString()).toBe('api-1') + // Reply envelope carries correlationId so the api can match the transaction. + expect(channelOf(out[2])).toBe(correlationId) + }) + + it('allows multiple replies on the same correlationId (progress... then okay)', () => { + // A streaming command sends several progress replies before the final okay, + // all on the same correlationId. The reply-path must survive until TTL sweep. + const wire = new DeviceWire() + const correlationId = 'txn_stream' + wire.receiveCommand([ + Buffer.from(KIND.DEVICE), + Buffer.from('provider-a'), + Buffer.from('serial-1'), + Buffer.from('api-1'), + heartbeatEnvelope('serial-1', correlationId), + ]) + + const first = wire.send(correlationId, replyEnvelope()) + const second = wire.send(correlationId, replyEnvelope()) + const third = wire.send(correlationId, replyEnvelope()) + + for (const out of [first, second, third]) { + expect(out).not.toBeNull() + expect(out![0].toString()).toBe(KIND.REPLY) + expect(out![1].toString()).toBe('api-1') + } + }) + + it('drops (returns null) a reply for an unknown correlationId', () => { + const wire = new DeviceWire() + const out = wire.send('txn_never_seen', replyEnvelope()) + expect(out).toBeNull() + }) +}) + +describe('DeviceWire: reply-path expiry', () => { + it('forgets a reply-path after its TTL so the map does not grow forever', () => { + let now = 1000 + const wire = new DeviceWire({ttl: 100, clock: () => now}) + + const correlationId = 'txn_expire' + wire.receiveCommand([ + Buffer.from(KIND.DEVICE), + Buffer.from('provider-a'), + Buffer.from('serial-1'), + Buffer.from('api-1'), + heartbeatEnvelope('serial-1', correlationId), + ]) + + now = 1201 // past ttl + wire.sweep() + + expect(wire.send(correlationId, replyEnvelope())).toBeNull() + }) +}) diff --git a/test/unit/units/device/adb-reverse/forwards.test.ts b/test/unit/units/device/adb-reverse/forwards.test.ts new file mode 100644 index 0000000000..af2ae9edff --- /dev/null +++ b/test/unit/units/device/adb-reverse/forwards.test.ts @@ -0,0 +1,253 @@ +import {describe, it, expect} from 'vitest' +import {ReverseForwards} from '../../../../../lib/units/device/plugins/adb-reverse/forwards.ts' + +// The user's own adb server snoops every `reverse:` request it relays to us and +// keeps an allow-list of remote -> local specs. It then refuses (with +// LOG(FATAL), killing itself) any A_OPEN whose service name is not a `local` +// spec it has on file. So we must mirror that allow-list with exactly the same +// grammar, and only ever open a reverse stream for a spec we saw registered. +describe('ReverseForwards: binding a reverse forward', () => { + it('binds a device port to the local spec the user registered', () => { + const forwards = new ReverseForwards() + + const result = forwards.exec('reverse:forward:tcp:7777;tcp:8080') + + expect(result).toEqual({action: 'bind', remote: 'tcp:7777', local: 'tcp:8080', devicePort: 7777}) + expect(forwards.resolveLocal('tcp:7777')).toBe('tcp:8080') + }) + + it('keeps each registered pair distinct, so two forwards can coexist', () => { + const forwards = new ReverseForwards() + + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + const second = forwards.exec('reverse:forward:tcp:9000;tcp:3000') + + expect(second).toEqual({action: 'bind', remote: 'tcp:9000', local: 'tcp:3000', devicePort: 9000}) + expect(forwards.resolveLocal('tcp:7777')).toBe('tcp:8080') + expect(forwards.resolveLocal('tcp:9000')).toBe('tcp:3000') + }) + + // A locally connected device refuses this: install_listener returns + // INSTALL_STATUS_CANNOT_REBIND, which adbd reports as FAIL. `adb reverse + // --no-rebind` must therefore fail visibly instead of silently keeping the + // old target, otherwise the user believes they rebound the port. + it('refuses norebind: when the remote is already bound, keeping the old target', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + + const result = forwards.exec('reverse:forward:norebind:tcp:7777;tcp:9999') + + expect(result).toEqual({action: 'rebind-refused', remote: 'tcp:7777'}) + expect(forwards.resolveLocal('tcp:7777')).toBe('tcp:8080') + }) + + it('norebind: does bind when the remote is not yet registered', () => { + const forwards = new ReverseForwards() + + const result = forwards.exec('reverse:forward:norebind:tcp:5555;tcp:1234') + + expect(result).toEqual({action: 'bind', remote: 'tcp:5555', local: 'tcp:1234', devicePort: 5555}) + expect(forwards.resolveLocal('tcp:5555')).toBe('tcp:1234') + }) +}) + +describe('ReverseForwards: removing reverse forwards', () => { + it('killforward: removes the specific remote entry', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + forwards.exec('reverse:forward:tcp:9000;tcp:3000') + + const result = forwards.exec('reverse:killforward:tcp:7777') + + expect(result).toEqual({action: 'unbind', remote: 'tcp:7777'}) + expect(forwards.resolveLocal('tcp:7777')).toBeUndefined() + expect(forwards.resolveLocal('tcp:9000')).toBe('tcp:3000') // unaffected + }) + + it('killforward-all: clears every registered entry', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + forwards.exec('reverse:forward:tcp:9000;tcp:3000') + + const result = forwards.exec('reverse:killforward-all') + + expect(result).toEqual({action: 'unbind-all'}) + expect(forwards.size).toBe(0) + }) +}) + +describe('ReverseForwards: list-forward', () => { + it('returns a list action without modifying the map', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + + const result = forwards.exec('reverse:list-forward') + + expect(result).toEqual({action: 'list'}) + expect(forwards.size).toBe(1) // map untouched + }) +}) + +describe('ReverseForwards: isLocalConfigured', () => { + it('returns true for a local spec that is in the allow-list', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + + expect(forwards.isLocalConfigured('tcp:8080')).toBe(true) + }) + + it('returns false for a local spec not in the allow-list', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + + expect(forwards.isLocalConfigured('tcp:9999')).toBe(false) + }) + + it('returns false after killforward removes the entry', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + forwards.exec('reverse:killforward:tcp:7777') + + expect(forwards.isLocalConfigured('tcp:8080')).toBe(false) + }) +}) + +describe('ReverseForwards: unknown/garbage commands', () => { + it('returns unknown for an unrecognised service name', () => { + const forwards = new ReverseForwards() + + const r1 = forwards.exec('reverse:garbage') + const r2 = forwards.exec('shell:ls') + + expect(r1).toEqual({action: 'unknown', name: 'reverse:garbage'}) + expect(r2).toEqual({action: 'unknown', name: 'shell:ls'}) + expect(forwards.size).toBe(0) // nothing was registered + }) + + it('rejects remote=tcp:* wildcard', () => { + const forwards = new ReverseForwards() + + // AOSP rejects pieces[1] starting with '*', i.e. the local spec + const result = forwards.exec('reverse:forward:tcp:7777;*') + + expect(result).toEqual({action: 'bad-forward', spec: 'tcp:7777;*'}) + expect(forwards.size).toBe(0) + }) + + // A locally connected device supports localabstract:/localfilesystem: remote + // specs, but our device-side listener (minirev) can only bind a TCP port. We + // must say so instead of registering a bogus devicePort 0, which would push a + // broken listener onto the device and leave the user with a silent no-op. + it('refuses a non-tcp remote spec rather than registering port 0', () => { + const forwards = new ReverseForwards() + + const result = forwards.exec('reverse:forward:localabstract:foo;tcp:8080') + + expect(result).toEqual({action: 'unsupported', remote: 'localabstract:foo'}) + expect(forwards.size).toBe(0) + }) + + it('refuses a tcp remote spec with a non-numeric or out-of-range port', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:forward:tcp:abc;tcp:8080')) + .toEqual({action: 'unsupported', remote: 'tcp:abc'}) + expect(forwards.exec('reverse:forward:tcp:0;tcp:8080')) + .toEqual({action: 'unsupported', remote: 'tcp:0'}) + expect(forwards.exec('reverse:forward:tcp:70000;tcp:8080')) + .toEqual({action: 'unsupported', remote: 'tcp:70000'}) + expect(forwards.size).toBe(0) + }) +}) + +// AOSP remove_listener returns INSTALL_STATUS_LISTENER_NOT_FOUND for a remote +// nobody bound, which handle_forward_request reports as +// FAIL "listener '' not found" (adb.cpp:1233). Reporting a silent success +// would make `adb reverse --remove` look like it worked on a typo'd port. +describe('ReverseForwards: killforward of an unbound remote', () => { + it('reports listener-not-found instead of a silent unbind', () => { + const forwards = new ReverseForwards() + + const result = forwards.exec('reverse:killforward:tcp:7777') + + expect(result).toEqual({action: 'listener-not-found', remote: 'tcp:7777'}) + }) +}) + +// AOSP splits the spec on ";" and demands exactly two non-empty pieces +// (adb.cpp:1188). A third piece means a malformed request, and accepting it +// would register a local spec ("tcp:8080;junk") that the user's own server +// never snooped — the exact mismatch that makes it LOG(FATAL) on our A_OPEN. +// +// A malformed *forward* is not the same failure as an unrecognised command. +// handle_forward_request recognises the `forward:` prefix and answers the parse +// failure itself, with "bad forward: " (adb.cpp:1190); only a command it +// does not recognise at all falls through to reverse_service's "not a reverse +// forwarding command" (daemon/services.cpp:69). The two texts reach the user, so +// they are part of the contract. +describe('ReverseForwards: malformed specs', () => { + it('reports a forward spec with more than two pieces as a bad forward', () => { + const forwards = new ReverseForwards() + + const result = forwards.exec('reverse:forward:tcp:7777;tcp:8080;tcp:9999') + + expect(result).toEqual({action: 'bad-forward', spec: 'tcp:7777;tcp:8080;tcp:9999'}) + expect(forwards.size).toBe(0) + }) + + // Same split for `killforward:`, but it demands exactly ONE non-empty piece + // (adb.cpp:1184). The reason text quotes the spec with the prefix already + // stripped, so an empty spec quotes an empty string. + it('reports an empty killforward spec as a bad killforward', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:killforward:')).toEqual({action: 'bad-killforward', spec: ''}) + }) + + it('reports a killforward spec with two pieces as a bad killforward', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:killforward:tcp:7777;tcp:8080')) + .toEqual({action: 'bad-killforward', spec: 'tcp:7777;tcp:8080'}) + }) + + // A missing separator is still a `forward:` the daemon recognises, so it is a + // bad forward rather than an unknown command. + it('reports a forward spec with no separator as a bad forward', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:forward:tcp:7777')) + .toEqual({action: 'bad-forward', spec: 'tcp:7777'}) + }) + + it('rejects a forward spec with an empty remote or local half', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:forward:;tcp:8080')) + .toEqual({action: 'bad-forward', spec: ';tcp:8080'}) + expect(forwards.exec('reverse:forward:tcp:7777;')) + .toEqual({action: 'bad-forward', spec: 'tcp:7777;'}) + expect(forwards.size).toBe(0) + }) + + // A single piece — the user typed `adb reverse tcp:7777` with no target. AOSP + // still recognises the command and reports the malformed spec itself, so this + // is a bad-forward rather than the generic fallback. + it('rejects a forward spec with no semicolon at all', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:forward:tcp:7777')) + .toEqual({action: 'bad-forward', spec: 'tcp:7777'}) + expect(forwards.size).toBe(0) + }) + + // The quoted spec has the `forward:` *and* `norebind:` prefixes stripped: + // AOSP advances the same `service` pointer past both before it splits, and + // that pointer is what it formats into the message (adb.cpp:1178-1190). + it('quotes a bad norebind spec without the norebind prefix', () => { + const forwards = new ReverseForwards() + + expect(forwards.exec('reverse:forward:norebind:tcp:7777')) + .toEqual({action: 'bad-forward', spec: 'tcp:7777'}) + }) +}) diff --git a/test/unit/units/device/adb-reverse/manager.test.ts b/test/unit/units/device/adb-reverse/manager.test.ts new file mode 100644 index 0000000000..aa5f9a0b39 --- /dev/null +++ b/test/unit/units/device/adb-reverse/manager.test.ts @@ -0,0 +1,409 @@ +import {describe, it, expect} from 'vitest' +// Oracles: adbkit's own packet encoder, plus the very ServiceMap and +// RollingCounter instances a real tcpusb Socket owns. Our reverse streams share +// both with the user's client streams, so faking them would hide exactly the +// collisions we care about. +// +// Imported by file path because @u4/adbkit declares only "." in its package +// exports; the bare '@u4/adbkit' import must stay first to break the dist import +// cycle. See packet.test.ts for the full explanation. Do not reorder. +import '@u4/adbkit' +import AdbkitPacket from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/packet.js' +import ServiceMap from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/servicemap.js' +import RollingCounter from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/rollingcounter.js' +import {ReverseStreamManager} from '../../../../../lib/units/device/plugins/adb-reverse/manager.ts' +import {ReverseForwards} from '../../../../../lib/units/device/plugins/adb-reverse/forwards.ts' +import {MinirevWriter} from '../../../../../lib/units/device/plugins/adb-reverse/minirevWriter.ts' + +/** + * Stand-in for adbkit's tcpusb Socket, using its real id counter and service + * map: those are the two pieces of socket state we share with client streams. + */ +function fakeSocket() { + const writes: Buffer[] = [] + return { + maxPayload: 4096, + writes, + remoteId: new RollingCounter(0xffffffff), + services: new ServiceMap(), + write(chunk: Buffer) { + writes.push(chunk) + return true + }, + } +} + +/** Stand-in for the minirev stream: records the frames we put on it. */ +function fakeConn() { + const writes: Buffer[] = [] + return { + writes, + ended: 0, + /** How many frames had been written when the stream was ended. */ + writesWhenEnded: -1, + write(chunk: Buffer) { + writes.push(chunk) + return true + }, + end() { + this.ended += 1 + this.writesWhenEnded = writes.length + }, + } +} + +/** + * Oracle for the minirev framing: MinirevWriter, the TypeScript port of the + * former forward/util/writer.js. The layout ([uint16LE connId][uint16LE length]) + * is minirev's, and minirev is a vendored binary with no source in the tree, + * so its own client is the only specification we have. + */ +function framedBy(connId: number, chunk: Buffer): Buffer { + const writer = new MinirevWriter(connId) + writer.write(chunk) + return Buffer.concat(drain(writer)) +} + +/** + * Oracle for minirev's end-of-connection marker, from MinirevWriter: + * its _flush pushes a header with length 0, which is exactly what + * MinirevReader turns back into a FIN on the receiving side. + */ +function finFramedBy(connId: number): Buffer { + const writer = new MinirevWriter(connId) + writer.end() + return Buffer.concat(drain(writer)) +} + +function drain(writer: {read(): Buffer | null}): Buffer[] { + const pieces: Buffer[] = [] + let piece: Buffer | null + while ((piece = writer.read())) { + pieces.push(piece) + } + return pieces +} + +/** A forwards map with one live binding, as `adb reverse tcp:7777 tcp:8080` leaves it. */ +function boundForwards() { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + return forwards +} + +describe('ReverseStreamManager: a new connection on the device', () => { + // The app on the device connected to the listening port, and minirev told us + // by sending a frame under a connId we have not seen. That is our cue to ask + // the user's server to connect on their side. + // + // The spec comes from the forwards map, never from the device: the user's + // server LOG(FATAL)s on an A_OPEN whose service name it has not snooped + // (adb.cpp:500, transport.cpp:1680), which kills their adb outright. + // + // The id must come from the socket's own RollingCounter, shared with client + // streams — a private counter would eventually hand out an id that already + // names one of their streams. Its first value is 2, and never 0, which is + // what keeps arg0 non-zero (create_remote_socket LOG(FATAL)s on id 0, + // sockets.cpp:544). + it('asks the user to connect to the spec registered for this forward', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OPEN, 2, 0, Buffer.from('tcp:8080\0')), + ]) + }) + + // Registration in the socket's ServiceMap is the only thing that makes the + // stream reachable: adbkit routes every incoming A_OKAY/A_WRTE/A_CLSE through + // `this.services.get(packet.arg1)` (socket.js:240-243) and silently discards + // the packet when nothing is registered. So without this the user's A_OKAY + // never arrives, and the stream stays stuck with a zero peer id forever. + // + // Asserted the way adbkit reaches it, not by inspecting our own map: the + // whole point is that *their* lookup finds our handler. + it('registers the stream where adbkit routes incoming packets', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + socket.writes.length = 0 + + // The user's server accepted the open and gave the stream its own id 31. + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + // Arrival is observable: the queued request bytes are released, now that + // the peer id is known. + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 2, 31, Buffer.from('GET / HTTP/1.1\r\n')), + ]) + }) + + // minirev's connId is the connection's identity, and it stays the same for + // every frame of it — a large request body arrives as several frames + // (MAX_PACKET_SIZE is 0xFFFF, reader.js splits on it). Opening a second + // stream for the second frame would give the user two TCP connections for + // one, split the request between them, and leak the first. + it('keeps one user stream per device connection, however many frames arrive', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('POST /upload HTTP/1.1\r\n')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + manager.handlePacket(1, Buffer.from('body')) + // The first chunk is still unacknowledged, so the body waits its turn; + // this ack is what releases it. + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + // Nothing is cleared here on purpose: the whole exchange must contain + // exactly one A_OPEN, and both chunks must ride the same pair of ids. + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OPEN, 2, 0, Buffer.from('tcp:8080\0')), + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 2, 31, Buffer.from('POST /upload HTTP/1.1\r\n')), + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 2, 31, Buffer.from('body')), + ]) + }) +}) + +describe('ReverseStreamManager: user → device framing', () => { + // Bytes that arrive from the user in an A_WRTE must reach the app on the + // device exactly as they would from a locally connected device. minirev frames + // them as [uint16LE connId][uint16LE length][payload] — the same framing the + // forward path uses for the other direction. We use the forward plugin's own + // ForwardWriter as the oracle rather than reproducing the layout from memory: + // minirev is a vendored binary and ForwardWriter is already talking to it. + it('frames user bytes with the minirev connId before writing to the device connection', () => { + const socket = fakeSocket() + const conn = fakeConn() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), conn) + + // Open a connection and acknowledge it. + manager.handlePacket(1, Buffer.from('ignored-first-chunk')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + // Now the user sends a response. + socket.services.get(2).handle({ + command: AdbkitPacket.A_WRTE, + arg0: 31, + arg1: 2, + data: Buffer.from('HTTP/1.1 200 OK\r\n'), + }) + + // The bytes must reach the device connection framed with the connId. + expect(conn.writes).toEqual([framedBy(1, Buffer.from('HTTP/1.1 200 OK\r\n'))]) + }) +}) + +describe('ReverseStreamManager: cleanup when a stream closes', () => { + // adbkit's RollingCounter will eventually wrap around (max is 0xffffffff but + // it is still finite). When it reissues a localId that is still in ServiceMap, + // ServiceMap.insert throws — crashing packet handling for every subsequent + // stream. And this.streams never shrinking means one stale ReverseStream entry + // per completed request for the whole life of the `adb connect` session. + // The fix: remove both entries as soon as the stream closes, regardless of + // which side closed it. + it('removes the stream from services when the user closes it', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + // The first localId the counter hands out is 2. + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + socket.services.get(2).handle({command: AdbkitPacket.A_CLSE, arg0: 31, arg1: 2}) + + expect(socket.services.get(2)).toBeNull() + }) + + // The same cleanup is owed when the *device* hangs up: the stream closes from + // the inside, without any packet from the user passing through the manager. + // Slice 3 made that path defer the close until the queue drains, so the + // notification has to come from the stream itself rather than from wherever + // the manager happens to be in its own control flow. + it('removes the stream from services when the device hangs up', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + manager.handlePacket(1, null) + + expect(socket.services.get(2)).toBeNull() + }) + + // minirev reuses connIds, so the next connection to the device port may well + // arrive under a connId we have already served. A closed stream left in the + // manager's own map would swallow it: the frame would be pushed into a dead + // stream (whose `ended` guard drops everything) and the user would never be + // asked to connect. Observable as the second A_OPEN, under a fresh localId. + it('opens a new stream when minirev reuses the connId of a closed connection', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('first')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + socket.services.get(2).handle({command: AdbkitPacket.A_CLSE, arg0: 31, arg1: 2}) + socket.writes.length = 0 + + manager.handlePacket(1, Buffer.from('second')) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OPEN, 3, 0, Buffer.from('tcp:8080\0')), + ]) + }) +}) + +describe('ReverseStreamManager: telling the device a connection is over', () => { + // The user closed their end. The app on the device is still holding its socket + // open, waiting on a connection nobody serves any more; minirev only learns + // otherwise from a zero-length frame (writer.js:24-30 writes it, reader.js:28-31 + // reads it). Without this the app hangs until its own timeout. + it('sends a minirev FIN frame for the connection the user closed', () => { + const socket = fakeSocket() + const conn = fakeConn() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), conn) + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + socket.services.get(2).handle({command: AdbkitPacket.A_CLSE, arg0: 31, arg1: 2}) + + expect(conn.writes).toEqual([finFramedBy(1)]) + }) + + // The FIN has to name the connection, so two live connections do not take each + // other down: a frame with the wrong connId would close a stranger's socket on + // the device. Two connections at once is also the normal case — a browser on + // the device opens several. + it('names the connection that closed, leaving the other one alone', () => { + const socket = fakeSocket() + const conn = fakeConn() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), conn) + manager.handlePacket(1, Buffer.from('first')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + manager.handlePacket(7, Buffer.from('second')) + socket.services.get(3).handle({command: AdbkitPacket.A_OKAY, arg0: 32, arg1: 3}) + + socket.services.get(3).handle({command: AdbkitPacket.A_CLSE, arg0: 32, arg1: 3}) + + expect(conn.writes).toEqual([finFramedBy(7)]) + }) + + // The minirev stream is shared by every connection on this forward — it is the + // one adb stream all of them are multiplexed into. Ending it to signal that a + // single connection finished would take down all the others, and the device + // would stop accepting new ones entirely. + it('keeps the shared minirev stream open when one connection ends', () => { + const socket = fakeSocket() + const conn = fakeConn() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), conn) + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + + socket.services.get(2).handle({command: AdbkitPacket.A_CLSE, arg0: 31, arg1: 2}) + + expect(conn.ended).toBe(0) + }) +}) + +describe('ReverseStreamManager: the forward itself goes away', () => { + // The binding is gone — `adb reverse --remove`, the `adb connect` session + // dropped, or the device left the group. Every connection multiplexed into it + // has to be closed, not just the last one: an unclosed stream leaves a socket + // open on the user's machine with nothing on our side serving it, and their + // server has no way to learn the binding is over. + // + // Two connections at once is the case that matters. With one, a close loop + // that stops after the first stream looks perfectly correct. + it('closes every open connection when the forward is torn down', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('first')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + manager.handlePacket(7, Buffer.from('second')) + socket.services.get(3).handle({command: AdbkitPacket.A_OKAY, arg0: 32, arg1: 3}) + socket.writes.length = 0 + + manager.end() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 2, 31, undefined), + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 3, 32, undefined), + ]) + }) + + // The minirev stream *is* the binding: closing it is what makes the device stop + // listening on the port. The forward plugin's own teardown is exactly this — + // `ForwardHandler.end = conn.end()` (forward/util/manager.js:85-87), which is + // what `plugin.removeForward` relies on. That it is the only unbind mechanism + // is visible in `manager.add`: rebinding the same devicePort first calls + // `this.remove(handlerId)` (manager.js:105-110) to drop the old stream. + // Leave it open and the device keeps accepting connections for a forward the + // user has already removed, and we would answer them with an A_OPEN the user's + // server no longer has snooped — LOG(FATAL), their adb dies (adb.cpp:500). + // + // Ordering matters as much as the call: each stream writes its FIN frame into + // this same conn, so the streams have to close while it is still open. + it('closes the shared minirev stream once the connections have said goodbye', () => { + const socket = fakeSocket() + const conn = fakeConn() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), conn) + manager.handlePacket(1, Buffer.from('first')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + manager.handlePacket(7, Buffer.from('second')) + socket.services.get(3).handle({command: AdbkitPacket.A_OKAY, arg0: 32, arg1: 3}) + + manager.end() + + expect(conn.ended).toBe(1) + // Both FIN frames were already on the stream when it was ended, so the + // device sees each connection finish rather than the stream vanishing + // under it. + expect(conn.writes).toEqual([finFramedBy(1), finFramedBy(7)]) + expect(conn.writesWhenEnded).toBe(2) + }) + + // A frame can still arrive after the teardown. `conn.end()` closes only the + // writable half — the stream was created with allowHalfOpen and ForwardReader + // sits on the readable half, so whatever minirev had already sent is still + // delivered as ('packet', ...) afterwards. + // + // Serving it would be the one thing we must never do. When the teardown came + // from `adb reverse --remove`, the user's server has already dropped the + // binding from reverse_forwards_, and an A_OPEN for a spec it no longer has + // snooped is answered with LOG(FATAL) — their adb process dies + // (adb.cpp:500, transport.cpp:1680). Registering the stream would leak an + // entry in the socket's ServiceMap too, for a forward that no longer exists. + it('ignores a late frame from minirev after the forward is gone', () => { + const socket = fakeSocket() + const conn = fakeConn() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), conn) + manager.end() + socket.writes.length = 0 + + manager.handlePacket(4, Buffer.from('too late')) + + expect(socket.writes).toEqual([]) + // Nothing was registered under the id the counter would have handed out. + expect(socket.services.get(2)).toBeNull() + }) +}) + +describe('ReverseStreamManager: the device connection closes', () => { + // The app on the device got its response and hung up. minirev reports that as + // a zero-length frame, which ForwardReader turns into ('packet', connId, null) + // (reader.js:28-31). Routing it as a data packet instead would leave the user's + // socket open forever, holding a connection nothing will ever answer — and a + // long-lived `adb connect` session leaks one per request. + it('closes the user stream when minirev reports the connection finished', () => { + const socket = fakeSocket() + const manager = new ReverseStreamManager(socket, 'tcp:7777', boundForwards(), fakeConn()) + manager.handlePacket(1, Buffer.from('GET / HTTP/1.1\r\n')) + socket.services.get(2).handle({command: AdbkitPacket.A_OKAY, arg0: 31, arg1: 2}) + socket.writes.length = 0 + + manager.handlePacket(1, null) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 2, 31, undefined), + ]) + }) +}) diff --git a/test/unit/units/device/adb-reverse/packet.test.ts b/test/unit/units/device/adb-reverse/packet.test.ts new file mode 100644 index 0000000000..c4835fb590 --- /dev/null +++ b/test/unit/units/device/adb-reverse/packet.test.ts @@ -0,0 +1,104 @@ +import {describe, it, expect} from 'vitest' +import {PassThrough} from 'node:stream' +// Oracle: adbkit's own packet code — the very code that parses whatever we emit +// and emits whatever we parse. +// +// Imported by file path because @u4/adbkit declares only "." in its package +// exports, so `@u4/adbkit/dist/...` is not importable under ESM — which is +// exactly why our own codec has to exist. The bare `@u4/adbkit` import below is +// load-order-significant, not decorative: adbkit's dist has an import cycle +// (utils -> parser/auth -> ... -> connection -> utils) that only resolves when +// the package entry point is evaluated first. Importing packetreader.js on its +// own throws "Cannot access 'Utils' before initialization". +// Test-only: production code must never reach into node_modules like this. +import '@u4/adbkit' +import AdbkitPacket from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/packet.js' +import PacketReader from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/packetreader.js' +import { + assemble, + A_SYNC, A_CNXN, A_OPEN, A_OKAY, A_CLSE, A_WRTE, A_AUTH, +} from '../../../../../lib/units/device/plugins/adb-reverse/packet.ts' + +// Every byte we put on the tcpusb socket is parsed by the user's real adb +// server, which drops the connection on a bad magic or checksum. So the only +// meaningful specification for our codec is "indistinguishable from adbkit's". +describe('assemble', () => { + it('encodes an A_OPEN with a payload exactly as adbkit does', () => { + const name = Buffer.from('tcp:8080\0') + + const ours = assemble(A_OPEN, 2, 0, name) + + expect(ours).toEqual(AdbkitPacket.assemble(AdbkitPacket.A_OPEN, 2, 0, name)) + }) + + // A payload-less packet must carry length 0 AND checksum 0, not a checksum + // over an empty buffer that happens to also be 0 — the distinction matters + // for A_OKAY/A_CLSE, which we send on every stream open and close. + it('encodes a payload-less A_OKAY exactly as adbkit does', () => { + const ours = assemble(A_OKAY, 2, 7, undefined) + + expect(ours).toEqual(AdbkitPacket.assemble(AdbkitPacket.A_OKAY, 2, 7, undefined)) + expect(ours).toHaveLength(24) + }) +}) + +// A single wrong constant sends the user's adb server a command it cannot parse. +describe('command constants', () => { + it('match adbkit byte-for-byte', () => { + expect({A_SYNC, A_CNXN, A_OPEN, A_OKAY, A_CLSE, A_WRTE, A_AUTH}).toEqual({ + A_SYNC: AdbkitPacket.A_SYNC, + A_CNXN: AdbkitPacket.A_CNXN, + A_OPEN: AdbkitPacket.A_OPEN, + A_OKAY: AdbkitPacket.A_OKAY, + A_CLSE: AdbkitPacket.A_CLSE, + A_WRTE: AdbkitPacket.A_WRTE, + A_AUTH: AdbkitPacket.A_AUTH, + }) + }) +}) + +// The real consumer of our bytes on the user's side is adbkit's PacketReader +// (the tcpusb Socket feeds it). It rejects bad magic and bad checksums, and it +// must survive a header split across TCP chunks — which is the normal case for +// a 4 KiB A_WRTE payload. +describe('round-trip through adbkit PacketReader', () => { + const readAll = (chunks: Buffer[]): Promise => new Promise((resolve, reject) => { + const stream = new PassThrough() + const packets: any[] = [] + const reader = new PacketReader(stream) + reader.on('packet', (p: any) => packets.push(p)) + reader.on('error', reject) + reader.on('end', () => resolve(packets)) + for (const chunk of chunks) { + stream.write(chunk) + } + stream.end() + }) + + it('parses an A_WRTE we assembled, payload intact', async() => { + const payload = Buffer.from('GET / HTTP/1.1\r\n\r\n') + + const packets = await readAll([assemble(A_WRTE, 2, 7, payload)]) + + expect(packets).toHaveLength(1) + expect(packets[0].command).toBe(A_WRTE) + expect(packets[0].arg0).toBe(2) + expect(packets[0].arg1).toBe(7) + expect(packets[0].data).toEqual(payload) + }) + + it('parses a stream of packets whose headers are split across chunks', async() => { + const payload = Buffer.alloc(4096, 0xab) + const wire = Buffer.concat([ + assemble(A_OPEN, 2, 0, Buffer.from('tcp:8080\0')), + assemble(A_WRTE, 2, 7, payload), + assemble(A_CLSE, 2, 7), + ]) + // Split mid-header of the second packet. + const cut = 33 + const packets = await readAll([wire.subarray(0, cut), wire.subarray(cut)]) + + expect(packets.map((p: any) => p.command)).toEqual([A_OPEN, A_WRTE, A_CLSE]) + expect(packets[1].data).toEqual(payload) + }) +}) diff --git a/test/unit/units/device/adb-reverse/replies.test.ts b/test/unit/units/device/adb-reverse/replies.test.ts new file mode 100644 index 0000000000..9803e1433f --- /dev/null +++ b/test/unit/units/device/adb-reverse/replies.test.ts @@ -0,0 +1,126 @@ +import {describe, it, expect} from 'vitest' +import {protocolString, replyFor} from '../../../../../lib/units/device/plugins/adb-reverse/replies.ts' +import {ReverseForwards} from '../../../../../lib/units/device/plugins/adb-reverse/forwards.ts' + +// We answer `reverse:` requests ourselves, so our bytes must be exactly what a +// real adbd sends — the user's `adb` client parses them with no tolerance. +// AOSP adb_io.cpp SendProtocolString: 4 lowercase hex length chars, then the +// payload. +describe('protocolString', () => { + it('prefixes the payload with its length as 4 hex chars', () => { + expect(protocolString('OK').toString()).toBe('0002OK') + }) + + it('counts length in bytes, not characters', () => { + // SendProtocolString takes s.size() — a byte count. A multi-byte error + // message would otherwise declare a short length and desync the client's + // parser for every byte that follows. + expect(protocolString('é').toString()).toBe('0002é') + }) +}) + +describe('replyFor', () => { + it('answers a successful bind with a bare OKAY', () => { + // On a device the `#if ADB_HOST` SendOkay is compiled out, so exactly one + // OKAY goes out, with no protocol string after it. + expect(replyFor({action: 'bind', remote: 'tcp:7777', local: 'tcp:8080', devicePort: 7777}).toString()) + .toBe('OKAY') + }) + + // FAIL is "FAIL" + SendProtocolString(reason) (adb_io.cpp), and the reason + // text is what `adb reverse` prints to the user, so it is part of the + // observable contract — not a detail we get to reword. + it('answers a refused norebind with FAIL and the AOSP reason text', () => { + expect(replyFor({action: 'rebind-refused', remote: 'tcp:7777'}).toString()) + .toBe('FAIL' + '001dcannot rebind existing socket') + }) + + it('answers killforward of an unbound remote with the AOSP not-found text', () => { + expect(replyFor({action: 'listener-not-found', remote: 'tcp:7777'}).toString()) + .toBe('FAIL' + "001dlistener 'tcp:7777' not found") + }) + + // reverse_service falls back to this exact text when handle_forward_request + // does not recognise the command (daemon/services.cpp:69). + it('answers an unrecognised command with the AOSP fallback text', () => { + expect(replyFor({action: 'unknown', name: 'reverse:garbage'}).toString()) + .toBe('FAIL' + '0020not a reverse forwarding command') + }) + + // A malformed `forward:` is a different failure from an unrecognised command, + // and the user sees the difference. handle_forward_request recognises the + // prefix and reports the parse failure itself as "bad forward: " + // (adb.cpp:1190); only a command it declines entirely reaches + // reverse_service's generic fallback. The spec is quoted back with + // `forward:`/`norebind:` already stripped, because AOSP has advanced its + // `service` pointer past them by that point. + it('answers a malformed forward with the AOSP bad-forward text', () => { + expect(replyFor({action: 'bad-forward', spec: 'tcp:7777;tcp:8080;tcp:9999'}).toString()) + .toBe('FAIL' + '0027bad forward: tcp:7777;tcp:8080;tcp:9999') + }) + + // A well-formed request we cannot serve is a bind failure, not a parse + // failure: minirev only listens on TCP ports. + it('answers an unsupported remote spec with a cannot-bind failure', () => { + const reply = replyFor({action: 'unsupported', remote: 'localabstract:foo'}).toString() + + expect(reply.startsWith('FAIL')).toBe(true) + expect(reply).toContain('cannot bind listener') + }) + + it('answers a malformed killforward with the AOSP bad-killforward text', () => { + expect(replyFor({action: 'bad-killforward', spec: 'a;b'}).toString()) + .toBe('FAIL' + '0014bad killforward: a;b') + }) + + it('never answers OKAY for a command it does not handle explicitly', () => { + // Guards against a permissive `default:` branch reporting success for a + // request we actually refused. + for (const command of [ + {action: 'unknown', name: 'reverse:garbage'}, + {action: 'rebind-refused', remote: 'tcp:1'}, + {action: 'listener-not-found', remote: 'tcp:1'}, + {action: 'unsupported', remote: 'localabstract:foo'}, + {action: 'bad-forward', spec: 'tcp:1;tcp:2;tcp:3'}, + {action: 'bad-killforward', spec: 'a;b'}, + ] as const) { + expect(replyFor(command).toString().startsWith('FAIL')).toBe(true) + } + }) +}) + +// The one reply with no OKAY prefix at all: on a device the `#if ADB_HOST` +// SendOkay is compiled out, so list-forward sends only the protocol string +// (adb.cpp:1137-1143). format_listeners writes one line per entry, and entries +// from `adb reverse` have no serial, so the serial column is "(reverse)" +// (adb_listeners.cpp). +describe('replyFor: list-forward', () => { + it('sends the listener list as a bare protocol string, without OKAY', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + + const reply = replyFor(forwards.exec('reverse:list-forward'), forwards).toString() + + expect(reply).toBe(protocolString('(reverse) tcp:7777 tcp:8080\n').toString()) + expect(reply.startsWith('OKAY')).toBe(false) + }) + + it('lists every registered forward, one line each', () => { + const forwards = new ReverseForwards() + forwards.exec('reverse:forward:tcp:7777;tcp:8080') + forwards.exec('reverse:forward:tcp:9000;tcp:3000') + + const reply = replyFor(forwards.exec('reverse:list-forward'), forwards).toString() + + expect(reply.slice(4)).toBe( + '(reverse) tcp:7777 tcp:8080\n' + + '(reverse) tcp:9000 tcp:3000\n' + ) + }) + + it('sends an empty list when nothing is bound', () => { + const forwards = new ReverseForwards() + + expect(replyFor(forwards.exec('reverse:list-forward'), forwards).toString()).toBe('0000') + }) +}) diff --git a/test/unit/units/device/adb-reverse/session.test.ts b/test/unit/units/device/adb-reverse/session.test.ts new file mode 100644 index 0000000000..1e2e05366e --- /dev/null +++ b/test/unit/units/device/adb-reverse/session.test.ts @@ -0,0 +1,595 @@ +import {describe, it, expect} from 'vitest' +import {EventEmitter} from 'node:events' +import {Duplex} from 'node:stream' +// Oracle: adbkit's own command constants and packet encoder. The bare +// '@u4/adbkit' import must stay first to break the dist import cycle; see +// packet.test.ts for the full explanation. Do not reorder. +import '@u4/adbkit' +import AdbkitPacket from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/packet.js' +import ServiceMap from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/servicemap.js' +import RollingCounter from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/rollingcounter.js' +import {attachReverse, isReverseOpen, ReverseSession} from '../../../../../lib/units/device/plugins/adb-reverse/session.ts' +import {MinirevWriter} from '../../../../../lib/units/device/plugins/adb-reverse/minirevWriter.ts' + +/** + * Stand-in for adbkit's PacketReader: an event emitter whose only 'packet' + * listener is the socket's own bound _handle, which is exactly the state a real + * Socket leaves its reader in (socket.js:57-58). + */ +function fakeReader() { + const delegated: unknown[] = [] + const reader = new EventEmitter() + reader.on('packet', (packet: unknown) => delegated.push(packet)) + return {reader, delegated} +} + +/** Stand-in for adbkit's tcpusb Socket, with its real id counter and service map. */ +function fakeSocket(reader: unknown) { + const writes: Buffer[] = [] + return { + maxPayload: 4096, + writes, + reader, + remoteId: new RollingCounter(0xffffffff), + services: new ServiceMap(), + write(chunk: Buffer) { + writes.push(chunk) + return true + }, + } +} + +/** + * Stand-in for the minirev stream. A real Duplex, so the production code can pipe + * it into MinirevReader and the framing is exercised for real rather than against + * a reimplementation of it. + */ +class FakeMinirev extends Duplex { + writes: Buffer[] = [] + ended = 0 + _write(chunk: Buffer, _enc: unknown, cb: () => void) { + this.writes.push(Buffer.from(chunk)) + cb() + } + _read() {} + end(...args: unknown[]) { + this.ended += 1 + // @ts-ignore -- pass through to Duplex + return super.end(...args) + } + /** Simulate minirev sending us bytes. */ + fromDevice(buf: Buffer) { + this.push(buf) + } +} + +function openPacket(name: string) { + return {command: AdbkitPacket.A_OPEN, arg0: 31, arg1: 0, data: Buffer.from(`${name}\0`)} +} + +/** + * Oracle for the minirev framing: MinirevWriter, the TypeScript port of the + * former forward/util/writer.js. The layout ([uint16LE connId][uint16LE length]) + * is minirev's, and minirev is a vendored binary with no source in the tree, + * so its own client is the only specification we have. + */ +function framedBy(connId: number, chunk: Buffer): Buffer { + const writer = new MinirevWriter(connId) + writer.write(chunk) + return Buffer.concat(drain(writer)) +} + +/** + * Oracle for minirev's end-of-connection marker, from MinirevWriter: + * its _flush pushes a header with length 0, which is exactly what + * MinirevReader turns back into a FIN on the receiving side. + */ +function finFramedBy(connId: number): Buffer { + const writer = new MinirevWriter(connId) + writer.end() + return Buffer.concat(drain(writer)) +} + +function drain(writer: {read(): Buffer | null}): Buffer[] { + const pieces: Buffer[] = [] + let piece: Buffer | null + while ((piece = writer.read())) { + pieces.push(piece) + } + return pieces +} + +// This is the regression test for the bug the whole feature exists to fix. Today +// `reverse:` reaches adbkit's Service, which relays it to the *provider's* adb +// server (service.js:92-99: client.getDevice(serial).transport()). That server +// snoops the request, the real adbd binds a listener on the device, and every +// reverse connection then lands on the provider host's loopback instead of the +// user's — a broken forward and a hole onto the provider's local services +// (ADB-REVERSE-PLAN.md §3.5). +describe('attachReverse: taking over reverse: requests', () => { + it('keeps a reverse: request away from adbkit, which would relay it to the provider adb server', () => { + const {reader, delegated} = fakeReader() + const socket = fakeSocket(reader) + + attachReverse(socket as never, async() => new FakeMinirev()) + reader.emit('packet', openPacket('reverse:forward:tcp:7777;tcp:8080')) + + expect(delegated).toEqual([]) + }) + + // Interception must be surgical: the bridge carries every other adb service on + // the same socket (shell:, sync:, the A_WRTE/A_OKAY traffic of open streams). + // Swallowing or reordering any of it would break `adb connect` itself, which + // works today. + it('leaves every other packet to adbkit untouched', () => { + const {reader, delegated} = fakeReader() + const socket = fakeSocket(reader) + const shell = openPacket('shell:ls') + const write = {command: AdbkitPacket.A_WRTE, arg0: 31, arg1: 2, data: Buffer.from('x')} + const connect = {command: AdbkitPacket.A_CNXN, arg0: 0x01000000, arg1: 4096, data: Buffer.from('host::\0')} + + attachReverse(socket as never, async() => new FakeMinirev()) + reader.emit('packet', shell) + reader.emit('packet', write) + reader.emit('packet', connect) + + expect(delegated).toEqual([shell, write, connect]) + }) + + // A real Socket leaves exactly one listener on its reader, so keeping only + // the first one would look correct forever here. Anything else sharing that + // reader — a future adbkit version, instrumentation of our own — has to go on + // working, and a dropped listener is invisible until something stops arriving. + it('delegates to every listener that was already on the reader', () => { + const {reader, delegated} = fakeReader() + const alsoDelegated: unknown[] = [] + reader.on('packet', (packet: unknown) => alsoDelegated.push(packet)) + const socket = fakeSocket(reader) + const shell = openPacket('shell:ls') + + attachReverse(socket as never, async() => new FakeMinirev()) + reader.emit('packet', shell) + + expect(delegated).toEqual([shell]) + expect(alsoDelegated).toEqual([shell]) + }) +}) + +describe('isReverseOpen', () => { + it('claims only an A_OPEN whose service name is a reverse: request', () => { + expect(isReverseOpen(openPacket('reverse:list-forward'))).toBe(true) + expect(isReverseOpen(openPacket('shell:ls'))).toBe(false) + }) + + // A payload is only a service name on an A_OPEN. The same bytes inside an + // A_WRTE are a user's data — stdin of a shell session, a file being pushed — + // and hijacking those would corrupt the stream they belong to. + it('does not claim a data packet that merely looks like one', () => { + expect(isReverseOpen({ + command: AdbkitPacket.A_WRTE, + arg0: 31, + arg1: 2, + data: Buffer.from('reverse:forward:tcp:1;tcp:2\0'), + })).toBe(false) + }) + + // adbkit throws 'Empty service name' for a payload shorter than 2 bytes + // (socket.js:213-215) and we must not crash ahead of it on a missing payload. + it('does not claim an A_OPEN with no payload', () => { + expect(isReverseOpen({command: AdbkitPacket.A_OPEN, arg0: 31, arg1: 0})).toBe(false) + }) +}) + +/** A promise the test settles by hand, to observe what happens while it is pending. */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (err: Error) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return {promise, resolve, reject} +} + +describe('ReverseSession.serve: binding a forward', () => { + // A device runs install_listener to completion before handle_forward_request + // answers (adb.cpp:1205-1212), so it never reports OKAY for a listener that is + // not up. Answering first would leave the user holding a forward we cannot + // serve, and our A_OPEN for it would name a spec their own server never + // snooped -> LOG(FATAL), their adb dies (adb.cpp:500). + it('puts the listener up on the device before it answers the user', async() => { + const {reader} = fakeReader() + const socket = fakeSocket(reader) + const ports: number[] = [] + const gate = deferred() + const session = new ReverseSession(socket as never, (port: number) => { + ports.push(port) + return gate.promise + }) + + const served = session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + + // The bind is the remote spec's port — what the device listens on — not the + // local one it forwards to; mixing them up would bind the user's port here. + expect(ports).toEqual([7777]) + expect(socket.writes).toEqual([]) + + gate.resolve(new FakeMinirev()) + await served + + expect(socket.writes.length).toBe(3) + }) + + // A listener that cannot come up is reported in the reply, not by refusing the + // stream. reverse_service hands handle_forward_request one end of a socketpair + // and returns the other whatever it decides (daemon/services.cpp:69-84), so the + // fd is valid even for a failed bind and the exchange is the usual three + // packets. The A_CLSE(arg0=0) failed-open form belongs to a service that could + // not be created at all, which `reverse:` never is. + it('answers a bind it could not put up with FAIL and the reason', async() => { + const socket = fakeSocket(fakeReader().reader) + const session = new ReverseSession(socket as never, async() => { + throw new Error('cannot bind to 7777: Address already in use') + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + + // "cannot bind listener: " is AOSP's INSTALL_STATUS_CANNOT_BIND + // text, where the reason comes from socket_spec_listen (adb.cpp:1226-1228). + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OKAY, 2, 31), + AdbkitPacket.assemble( + AdbkitPacket.A_WRTE, 2, 31, + Buffer.from( + 'FAIL0041cannot bind listener: cannot bind to 7777: Address already in use', + 'ascii' + ) + ), + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 2, 31), + ]) + }) + + // The map has to be rolled back, because exec() registered the forward before + // we knew the bind would fail. adbd leaves nothing in listener_list when + // install_listener fails, so `adb reverse --list` must not show the forward + // either. Note this is not a LOG(FATAL) risk on its own — the user's server + // snoops the request when it sends it (sockets.cpp:560) and keeps its entry + // regardless — but our map is what gates the A_OPEN we push back, and it may + // only ever be a subset of theirs. + it('forgets a forward whose listener never came up', async() => { + const socket = fakeSocket(fakeReader().reader) + const session = new ReverseSession(socket as never, async() => { + throw new Error('cannot bind to 7777: Address already in use') + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:list-forward')) + + expect(socket.writes[1].subarray(24).toString()).toBe('0000') + }) + + // A rebind repurposes the listener that is already up; it does not replace it. + // install_listener, on finding an existing listener for the same local_name, + // assigns `l->connect_to = connect_to` and returns OK without touching the + // socket — socket_spec_listen is only reached on the path that creates a new one + // (adb_listeners.cpp:190-226). Tearing ours down and opening a second minirev + // stream would leave the device port unbound for a moment and would cut the + // connections already running through it, neither of which happens on a locally + // connected device. + // + // The two guard assertions below hold trivially today, since nothing tears a + // forward down yet; they are here to fail the day slice 5.5's teardown is wired + // to the wrong case. `unbind` must end the manager, a rebind must not — the + // difference is that killforward calls remove_listener while a rebind does not. + it('repurposes the listener it already has when the user rebinds a forward', async() => { + const socket = fakeSocket(fakeReader().reader) + const ports: number[] = [] + const conns: FakeMinirev[] = [] + const session = new ReverseSession(socket as never, async(port: number) => { + ports.push(port) + const conn = new FakeMinirev() + conns.push(conn) + return conn + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:9090')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:list-forward')) + + expect(ports).toEqual([7777]) + expect(conns.length).toBe(1) + expect(conns[0].ended).toBe(0) + // The forward now points at the new target, which is the whole purpose of + // repurposing rather than rebuilding: the manager resolves the local spec + // per connection, so the listener that is already up starts connecting to + // 9090 with no gap. + expect(socket.writes[1].subarray(24).toString()).toBe('001c(reverse) tcp:7777 tcp:9090\n') + }) + + // A bind that failed leaves nothing behind, so asking again has to try again. + // The failure is usually transient and the retry is the obvious next thing the + // user does — the port was taken by something that has since exited, and + // `adb reverse tcp:7777 tcp:8080` gets typed a second time. + // + // Treating that retry as a rebind is the worst of the failure modes available + // here: we answer OKAY, bind nothing, and the forward appears in + // `adb reverse --list` with no listener under it. Nothing reports an error at + // any point — the app on the device just finds the port closed. + it('binds again when the user retries a forward that failed', async() => { + const socket = fakeSocket(fakeReader().reader) + const ports: number[] = [] + let failNext = true + const session = new ReverseSession(socket as never, async(port: number) => { + ports.push(port) + if (failNext) { + failNext = false + throw new Error('cannot bind to 7777: Address already in use') + } + return new FakeMinirev() + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + + expect(ports).toEqual([7777, 7777]) + expect(socket.writes[1].subarray(24).toString()).toBe('OKAY') + }) +}) + +// A `reverse:` command that needs nothing from the device is answered entirely +// from our own state. The wire sequence is fixed by AOSP's reverse_service +// (daemon/services.cpp:69-84): it hands handle_forward_request one end of a +// socketpair, closes it, and returns the other — so the stream carries the reply +// and then hits EOF. Three packets from us, in this order, every time. +describe('ReverseSession.serve: commands answered without the device', () => { + it('answers list-forward with OKAY, the reply bytes and a close', async() => { + const socket = fakeSocket(fakeReader().reader) + const session = new ReverseSession(socket as never, async() => new FakeMinirev()) + + await session.serve(openPacket('reverse:list-forward')) + + // localId 2 is the first id RollingCounter hands out; 31 is their arg0. + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OKAY, 2, 31), + // No forward is bound, so format_listeners produces nothing and the + // protocol string is a bare length of zero. list-forward is the one + // reply with no OKAY prefix (adb.cpp:1137-1143). + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 2, 31, Buffer.from('0000', 'ascii')), + // arg0 is our localId, not 0: the open succeeded, we just sent A_OKAY + // for it. Zero there is the protocol's failed-open form (adb.cpp:597). + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 2, 31), + ]) + }) + + // A refusal travels the same three packets, only with FAIL in the payload. + // Failing to open the stream instead would be wrong: reverse_service returns a + // valid fd whatever handle_forward_request decides, so the user's client is + // waiting to read a reason, not to see the open rejected. + it('answers an unrecognised reverse: command with FAIL and its reason', async() => { + const socket = fakeSocket(fakeReader().reader) + const session = new ReverseSession(socket as never, async() => new FakeMinirev()) + + await session.serve(openPacket('reverse:nonsense')) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OKAY, 2, 31), + AdbkitPacket.assemble( + AdbkitPacket.A_WRTE, 2, 31, + Buffer.from('FAIL0020not a reverse forwarding command', 'ascii') + ), + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 2, 31), + ]) + }) + + // Each command stream needs its own id out of the socket's own counter. Reusing + // one would collide with the client's streams in the same ServiceMap, and their + // server routes by it — two live streams under one id cross their traffic. + it('takes a fresh id from the socket counter for each command', async() => { + const socket = fakeSocket(fakeReader().reader) + const session = new ReverseSession(socket as never, async() => new FakeMinirev()) + + await session.serve(openPacket('reverse:list-forward')) + await session.serve(openPacket('reverse:list-forward')) + + expect(socket.writes[3]).toEqual(AdbkitPacket.assemble(AdbkitPacket.A_OKAY, 3, 31)) + expect(socket.writes[5]).toEqual(AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 3, 31)) + }) + + // `adb reverse --list` has to report this session's own forwards, which is + // only visible once the map is non-empty: with no forwards registered, a reply + // that ignored the map entirely would produce the same empty '0000' string. + // The column order is AOSP's format_listeners (adb_listeners.cpp:129-144) — + // what the listener binds on the device first, where it connects second. + it('reports the forwards it has bound when the user lists them', async() => { + const {reader} = fakeReader() + const socket = fakeSocket(reader) + const session = new ReverseSession(socket as never, async() => new FakeMinirev()) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:list-forward')) + + expect(socket.writes[1].subarray(24).toString()).toBe('001c(reverse) tcp:7777 tcp:8080\n') + }) +}) + +// Slice 5.5: removing a bound forward must tear down its manager and free the +// minirev stream so the device stops listening. The user sees this as +// `adb reverse --remove tcp:7777` or `adb reverse --remove-all`. +describe('ReverseSession.serve: releasing forwards', () => { + // killforward calls remove_listener, which closes the listening fd on the + // device (fact 23). The only mechanism that closes a minirev listener is + // conn.end(); manager.end() is responsible for calling it. If we skip + // manager.end() the device keeps the port open and we eventually push + // A_OPEN with a spec the user's server no longer knows -> LOG(FATAL). + it('closes the minirev stream when the user removes a forward', async() => { + const socket = fakeSocket(fakeReader().reader) + const conns: FakeMinirev[] = [] + const session = new ReverseSession(socket as never, async() => { + const conn = new FakeMinirev() + conns.push(conn) + return conn + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:killforward:tcp:7777')) + + expect(conns[0].ended).toBe(1) + }) + + // After a forward is removed its slot in `bound` must be cleared, or the + // next `adb reverse tcp:7777 tcp:8080` would be misread as a rebind: we + // would answer OKAY, touch nothing, and leave the user with a forward in + // `--list` whose listener is not actually up. + it('allows a fresh bind after the forward is removed', async() => { + const socket = fakeSocket(fakeReader().reader) + const ports: number[] = [] + const session = new ReverseSession(socket as never, async(port: number) => { + ports.push(port) + return new FakeMinirev() + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + await session.serve(openPacket('reverse:killforward:tcp:7777')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:9090')) + + // openMinirev must have been called twice. + expect(ports).toEqual([7777, 7777]) + expect(socket.writes[1].subarray(24).toString()).toBe('OKAY') + }) + + // `adb reverse --remove` on a forward that was never bound gets FAIL from + // adbd (adb_listeners.cpp: remove_listener returns NOT_FOUND). There is no + // manager to end and nothing to clean up in `bound` — but the reply must + // still follow the standard three-packet path. + it('answers killforward for an unknown forward with FAIL', async() => { + const socket = fakeSocket(fakeReader().reader) + const session = new ReverseSession(socket as never, async() => new FakeMinirev()) + + await session.serve(openPacket('reverse:killforward:tcp:9999')) + + // "listener 'tcp:9999' not found" is AOSP's INSTALL_STATUS_LISTENER_NOT_FOUND + // text (adb.cpp:1232-1234); the hex length encodes the reason string only. + const payload = socket.writes[1].subarray(24).toString() + expect(payload).toBe("FAIL001dlistener 'tcp:9999' not found") + }) + + // killforward-all should tear down every manager, mirroring what a + // locally-connected device does when it calls remove_all_listeners. + it('closes all minirev streams when the user removes all forwards', async() => { + const socket = fakeSocket(fakeReader().reader) + const conns: FakeMinirev[] = [] + const session = new ReverseSession(socket as never, async() => { + const conn = new FakeMinirev() + conns.push(conn) + return conn + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + await session.serve(openPacket('reverse:forward:tcp:9999;tcp:3000')) + socket.writes.length = 0 + await session.serve(openPacket('reverse:killforward-all')) + + expect(conns[0].ended).toBe(1) + expect(conns[1].ended).toBe(1) + }) + + // session.end() is called when the `adb connect` session ends: the socket + // disconnects, the device leaves its group, or the plugin stops. It must + // release everything, so the same as killforward-all but without an answer. + it('session.end() closes all bound managers', async() => { + const socket = fakeSocket(fakeReader().reader) + const conns: FakeMinirev[] = [] + const session = new ReverseSession(socket as never, async() => { + const conn = new FakeMinirev() + conns.push(conn) + return conn + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + await session.serve(openPacket('reverse:forward:tcp:9999;tcp:3000')) + session.end() + + expect(conns[0].ended).toBe(1) + expect(conns[1].ended).toBe(1) + }) +}) + +// Slice 5.4: once a forward is successfully bound, minirev starts sending us +// frames over the conn. Those must reach ReverseStreamManager.handlePacket, which +// then opens a reverse connection on the user's side by emitting an A_OPEN. +// +// The real MinirevReader is used here, not a reimplementation of it: FakeMinirev +// is a Duplex, and fromDevice() pushes raw bytes that flow through the actual +// Transform. setImmediate is needed because the pipe is async — the 'packet' event +// is not emitted synchronously from the push. +describe('ReverseSession.serve: routing minirev frames after a successful bind', () => { + // End-to-end path: user binds a forward, then an app on the device connects to + // the port. The frame from minirev must trigger an A_OPEN so the user's server + // can create the connection on their machine. + // + // This is the first test that exercises the full path: bind -> frame -> A_OPEN. + // If conn is piped into ForwardReader and handlePacket is wired correctly, we + // should see an A_OPEN with the local spec from the forwards map (tcp:8080) and + // a non-zero localId. + it('opens a connection on the user side when minirev sends a frame after binding', async() => { + const {reader} = fakeReader() + const socket = fakeSocket(reader) + const conns: FakeMinirev[] = [] + const session = new ReverseSession(socket as never, async() => { + const conn = new FakeMinirev() + conns.push(conn) + return conn + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + socket.writes.length = 0 + + // Simulate minirev: an app on the device connected to port 7777 under connId 1. + conns[0].fromDevice(framedBy(1, Buffer.from('GET / HTTP/1.1\r\n'))) + await new Promise(r => setImmediate(r)) + + // ForwardReader emitted ('packet', 1, data) -> manager.handlePacket(1, data) + // -> resolveLocal('tcp:7777') === 'tcp:8080' -> A_OPEN with spec 'tcp:8080\0'. + // The exact localId is not checked here (the counter consumed id=2 for the + // command stream already); manager.test.ts covers that end-to-end. + // What matters is that a packet went out and the spec is right. + expect(socket.writes.length).toBeGreaterThan(0) + // Bytes 0-23 are the fixed-length ADB message header; data starts at 24. + expect(socket.writes[0].subarray(24).toString()).toBe('tcp:8080\0') + }) + + // Two forwards in one session: a frame on each conn must open a stream with + // the *correct* local spec. Without this test, passing the wrong `remote` to + // the manager constructor (e.g. command.local instead of command.remote) would + // be invisible, because with only one forward both specs resolve to the same + // forward and the wrong one just happens to match. + it('routes a frame to the correct forward when two are bound', async() => { + const {reader} = fakeReader() + const socket = fakeSocket(reader) + const conns: FakeMinirev[] = [] + const session = new ReverseSession(socket as never, async() => { + const conn = new FakeMinirev() + conns.push(conn) + return conn + }) + + await session.serve(openPacket('reverse:forward:tcp:7777;tcp:8080')) + await session.serve(openPacket('reverse:forward:tcp:9999;tcp:3000')) + socket.writes.length = 0 + + // A frame arrives on the *second* forward's conn (connId 5, arbitrary). + conns[1].fromDevice(framedBy(5, Buffer.from('hello'))) + await new Promise(r => setImmediate(r)) + + // The A_OPEN must carry the second forward's local spec (tcp:3000), not the + // first's. A mismatch here would send the user's client to the wrong port. + expect(socket.writes.length).toBeGreaterThan(0) + expect(socket.writes[0].subarray(24).toString()).toBe('tcp:3000\0') + }) +}) diff --git a/test/unit/units/device/adb-reverse/stream.test.ts b/test/unit/units/device/adb-reverse/stream.test.ts new file mode 100644 index 0000000000..a61ececf3a --- /dev/null +++ b/test/unit/units/device/adb-reverse/stream.test.ts @@ -0,0 +1,398 @@ +import {describe, it, expect} from 'vitest' +// Oracle: adbkit's own packet encoder. Our bytes land in the user's real adb +// server, so "identical to what a working ADB implementation emits" is the only +// specification worth asserting against. See packet.test.ts for why this is +// imported by file path and why the bare '@u4/adbkit' import must come first. +import '@u4/adbkit' +import AdbkitPacket from '../../../../../node_modules/@u4/adbkit/dist/adb/tcpusb/packet.js' +import {ReverseStream} from '../../../../../lib/units/device/plugins/adb-reverse/stream.ts' +import {A_OKAY, A_WRTE, A_CLSE} from '../../../../../lib/units/device/plugins/adb-reverse/packet.ts' + +/** Stand-in for adbkit's tcpusb Socket: records what we would put on the wire. */ +function fakeSocket() { + const writes: Buffer[] = [] + return { + maxPayload: 4096, + writes, + write(chunk: Buffer) { + writes.push(chunk) + return true + }, + } +} + +/** + * Stand-in for the minirev connection to the device end of this reverse stream. + * Only the sink half matters here; framing is the caller's job, not ours. + */ +function fakeDevice() { + const writes: Buffer[] = [] + return { + writes, + ended: 0, + write(chunk: Buffer) { + writes.push(chunk) + return true + }, + end() { + this.ended++ + }, + } +} + +describe('ReverseStream: opening the stream', () => { + // We are the initiator here, which is the mirror image of adbkit's Service. + // arg0 carries our own id; arg1 MUST be 0 because we do not yet know the + // user's id for this stream, and a non-zero arg1 would be read as a delayed + // ack window the user's server never negotiated. + it('asks the user for the local spec they registered, with no peer id yet', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + + stream.open() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OPEN, 7, 0, Buffer.from('tcp:8080\0')), + ]) + }) +}) + +describe('ReverseStream: device -> user', () => { + // The user's A_OKAY is what tells us their id for this stream; every packet + // we send afterwards must carry it as arg1, or their server routes it to no + // socket at all. adbkit's Service reads the pair as (arg0=peer, arg1=ours), + // so on the way back the ids swap: we send (arg0=ours, arg1=theirs). + it('forwards a device chunk once the user has acknowledged the open', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + socket.writes.length = 0 + + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + stream.fromDevice(Buffer.from('GET / HTTP/1.1\r\n')) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 7, 31, Buffer.from('GET / HTTP/1.1\r\n')), + ]) + }) + + // Not an optimisation we invented: with delayed ack unnegotiated, AOSP's + // sender stops reading its socket until the ack lands ("acks not deferred, + // blocking", sockets.cpp:232-236). A second unacked A_WRTE is a protocol + // violation, so the second chunk has to wait. + it('holds a second chunk until the user acknowledges the first', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + socket.writes.length = 0 + + stream.fromDevice(Buffer.from('first')) + stream.fromDevice(Buffer.from('second')) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 7, 31, Buffer.from('first')), + ]) + }) + + // ...and once it lands, the held bytes go out, still in order. + it('releases the held chunk when the acknowledgement arrives', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + stream.fromDevice(Buffer.from('first')) + stream.fromDevice(Buffer.from('second')) + socket.writes.length = 0 + + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 7, 31, Buffer.from('second')), + ]) + }) + + // The device can produce bytes before the user has accepted the open — the + // TCP connection to minirev exists as soon as the app connects. Writing them + // out then would mean arg1=0, and the user's server drops any A_WRTE with a + // zero id without a word (adb.cpp:619), losing the bytes for good. + it('does not write device bytes before the open is acknowledged', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + socket.writes.length = 0 + + stream.fromDevice(Buffer.from('early')) + + expect(socket.writes).toEqual([]) + }) + + // ...and they must not be dropped either: the same bytes go out, with the + // real peer id, as soon as the open lands. + it('delivers the early bytes once the open is acknowledged', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.fromDevice(Buffer.from('early')) + socket.writes.length = 0 + + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 7, 31, Buffer.from('early')), + ]) + }) +}) + +describe('ReverseStream: user -> device', () => { + // The acknowledgement is not optional bookkeeping: the user's server stops + // reading its socket after an unacked write (sockets.cpp:232), so a missing + // A_OKAY stalls the response body forever. It must carry no payload at all — + // a 4-byte one would be read as a delayed-ack credit, and any other size is + // rejected outright ("invalid A_OKAY payload size", adb.cpp:568). + it('passes the user response to the device and acknowledges it', () => { + const socket = fakeSocket() + const device = fakeDevice() + const stream = new ReverseStream(socket, 7, 'tcp:8080', device) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + socket.writes.length = 0 + + stream.handle({command: A_WRTE, arg0: 31, arg1: 7, data: Buffer.from('HTTP/1.1 200 OK')}) + + expect(device.writes).toEqual([Buffer.from('HTTP/1.1 200 OK')]) + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_OKAY, 7, 31, undefined), + ]) + }) +}) + +describe('ReverseStream: closing the stream', () => { + // The user's server only acts on a close whose arg1 names one of its own + // sockets (adb.cpp:594 ignores the packet unless arg1 != 0), so the peer id + // learned from their A_OKAY has to be echoed here too. Without this packet + // their end of the connection leaks: nothing else tells them we are done. + it('tells the user to close the stream it acknowledged', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + socket.writes.length = 0 + + stream.end() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 7, 31, undefined), + ]) + }) + + // A zero arg0 is the protocol's way of saying "that open never came up" + // (adb.cpp:597 documents CLOSE(0, remote-id) as the failed-OPEN form, and + // adbkit's Service does the same: `opened ? localId : 0`). We get here when + // the user's port is not listening — Charles closed, mitmproxy not started — + // so we never saw their A_OKAY and have no id of our own worth naming. + it('reports a close before the open was acknowledged as a failed open', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + socket.writes.length = 0 + + stream.end() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 0, 0, undefined), + ]) + }) + + // The user hanging up is the normal way a reverse connection dies: their + // browser closes the tab, or the port was never listening in the first place + // (Charles not started), in which case their server answers our A_OPEN with + // A_CLSE instead of A_OKAY. Either way the minirev connection on the device + // has to go, or the app sits waiting on a socket nobody will ever answer. + it('closes the device connection when the user closes the stream', () => { + const socket = fakeSocket() + const device = fakeDevice() + const stream = new ReverseStream(socket, 7, 'tcp:8080', device) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + + stream.handle({command: A_CLSE, arg0: 31, arg1: 7}) + + expect(device.ended).toBe(1) + }) + + // AOSP echoes the close rather than staying silent: the incoming A_CLSE runs + // local_socket_close, which calls peer->shutdown (sockets.cpp:352) — that is + // remote_socket_shutdown (sockets.cpp:552), and it puts an A_CLSE on the wire. + // adbkit's Service does the same by routing the packet into end(). It cannot + // loop: by the time ours arrives their socket is gone, so find_local_socket + // finds nothing and adb.cpp:594 drops it. + it('echoes the close back to the user', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + socket.writes.length = 0 + + stream.handle({command: A_CLSE, arg0: 31, arg1: 7}) + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 7, 31, undefined), + ]) + }) + + // Closing twice is the normal case, not a corner case: the manager ends every + // stream when the `adb connect` socket drops, and the user's own A_CLSE may + // already have taken this one down. A second A_CLSE on the wire would name an + // id their server has since handed to a different stream, closing it instead. + // adbkit guards every handler with `if (this.ended) return`. + it('closes only once, however many times it is asked', () => { + const socket = fakeSocket() + const device = fakeDevice() + const stream = new ReverseStream(socket, 7, 'tcp:8080', device) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + socket.writes.length = 0 + + stream.handle({command: A_CLSE, arg0: 31, arg1: 7}) + stream.end() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 7, 31, undefined), + ]) + expect(device.ended).toBe(1) + }) + + // The app on the device hanging up is the other normal end of a reverse + // connection: it got its HTTP response and closed the socket. minirev reports + // that as a frame with length 0, which ForwardReader turns into + // ('packet', connId, null) (reader.js:28-31). Nothing else tells the user we + // are done, so their end would leak exactly as it would on our own close. + it('closes the user stream when the app on the device hangs up', () => { + const socket = fakeSocket() + const device = fakeDevice() + const stream = new ReverseStream(socket, 7, 'tcp:8080', device) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + socket.writes.length = 0 + + stream.fromDeviceEnd() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 7, 31, undefined), + ]) + }) + + // AOSP defers a socket close when its packet queue is still non-empty: + // local_socket_close adds it to local_socket_closing_list and only destroys + // it in local_socket_flush_incoming once the queue drains (sockets.cpp:161). + // In practice this is unreachable on a local device because AOSP stops reading + // the fd while a write is unacked ("acks not deferred, blocking", sockets.cpp:232), + // so it would never even see the EOF until then. But minirev multiplexes many + // connections into one adb stream, so it cannot pause reading: we can receive a + // FIN frame for connection A while A's A_WRTE is still unacknowledged. + // Correct behaviour: flush ALL pending bytes first, then close. + // Three chunks: first goes out immediately, second+third are queued. FIN arrives + // while the queue is non-empty. The close must only fire after the LAST chunk + // has been sent — not after the second (when the third is still waiting). + it('sends all pending bytes before closing when the device hangs up mid-flight', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + // chunk1 goes out immediately; chunk2 and chunk3 queue up + stream.fromDevice(Buffer.from('chunk1')) + stream.fromDevice(Buffer.from('chunk2')) + stream.fromDevice(Buffer.from('chunk3')) + socket.writes.length = 0 + + // FIN arrives while chunk2 and chunk3 are still in the pending queue + stream.fromDeviceEnd() + expect(socket.writes).toEqual([]) // close not sent yet + + // First ack: chunk2 goes out; chunk3 is still pending → no close yet + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 7, 31, Buffer.from('chunk2')), + ]) + + socket.writes.length = 0 + + // Second ack: chunk3 goes out, queue drained → close follows + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_WRTE, 7, 31, Buffer.from('chunk3')), + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 7, 31, undefined), + ]) + }) + + // The ordinary shape of a request/response, and the case that must NOT wait: + // the device sent its request, we forwarded it, and it hung up while our + // A_WRTE was still unacknowledged. The queue is empty, so there is nothing + // left to send and nothing left to wait for — the close goes out at once. + // + // Waiting for the ack here would be a deadlock disguised as caution: the + // trigger to re-check would be an incoming A_OKAY that the user has no reason + // to follow with anything, and the stream would sit open for the whole life of + // the `adb connect` session. It is also not what a local device does: AOSP + // defers only while there are packets left to write out (fd_full is + // `!packet_queue.empty()`, sockets.cpp:160-163) — acks play no part in it. + // + // No bytes are at risk. The A_WRTE is already on the same TCP stream ahead of + // the A_CLSE, and the user's own deferred close is what guarantees the rest: + // it flushes its queue into the fd before destroying the socket (the RFC 1122 + // reasoning quoted at sockets.cpp:280-286). + it('closes at once when the device hangs up with nothing left to send', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + // Goes out immediately, leaving the queue empty and the window taken. + stream.fromDevice(Buffer.from('GET / HTTP/1.1\r\n')) + socket.writes.length = 0 + + stream.fromDeviceEnd() + + expect(socket.writes).toEqual([ + AdbkitPacket.assemble(AdbkitPacket.A_CLSE, 7, 31, undefined), + ]) + }) + + // Packets already in flight when we close still arrive afterwards. Answering + // one means putting our localId on the wire after we told them it was dead — + // by then their server may have reissued that id, so the A_OKAY would credit + // a write window on somebody else's stream. adbkit returns early from every + // handler once ended, and the bytes have nowhere to go regardless: the device + // connection is already gone. + it('answers nothing that arrives after the close', () => { + const socket = fakeSocket() + const device = fakeDevice() + const stream = new ReverseStream(socket, 7, 'tcp:8080', device) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + stream.end() + socket.writes.length = 0 + + stream.handle({command: A_WRTE, arg0: 31, arg1: 7, data: Buffer.from('late')}) + + expect(socket.writes).toEqual([]) + expect(device.writes).toEqual([]) + }) + + // The same hazard from the other direction: minirev can hand us a chunk that + // was already in its buffer when the stream died. An A_WRTE under a retired + // localId is worse than a stray A_OKAY, because it delivers our bytes into + // whatever stream now owns that id. + it('sends no device bytes that turn up after the close', () => { + const socket = fakeSocket() + const stream = new ReverseStream(socket, 7, 'tcp:8080', fakeDevice()) + stream.open() + stream.handle({command: A_OKAY, arg0: 31, arg1: 7}) + stream.end() + socket.writes.length = 0 + + stream.fromDevice(Buffer.from('late')) + + expect(socket.writes).toEqual([]) + }) +}) diff --git a/test/unit/units/processor/presence.test.ts b/test/unit/units/processor/presence.test.ts new file mode 100644 index 0000000000..7f80e168e7 --- /dev/null +++ b/test/unit/units/processor/presence.test.ts @@ -0,0 +1,101 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {PresenceTracker} from '../../../../lib/units/processor/presence.ts' +import {deviceKey} from '../../../../lib/wire/frame.ts' + +// Presence tracking for the processor, on top of TTLSet. +// +// The uniqueness KEY is the deviceKey (providerName + serial) — two emulators +// sharing "emulator-5554" behind different providers must not collapse. But the +// emitted events carry the bare serial, matching the existing wire payload +// (DevicePresentMessage/DeviceAbsentMessage take {serial}). So the tracker +// remembers deviceKey -> serial to report the right serial on timeout. + +describe('PresenceTracker', () => { + const TTL = 30_000 + + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('emits present with the serial the first time a device is bumped', () => { + const tracker = new PresenceTracker(TTL) + const present: string[] = [] + tracker.on('present', ({serial}) => present.push(serial)) + + tracker.bump('provider-a', 'serial-1', Date.now()) + + expect(present).toEqual(['serial-1']) + }) + + it('does not re-emit present on a subsequent heartbeat bump', () => { + const tracker = new PresenceTracker(TTL) + const present: string[] = [] + tracker.on('present', ({serial}) => present.push(serial)) + + tracker.bump('provider-a', 'serial-1', Date.now()) + tracker.bump('provider-a', 'serial-1', Date.now()) + + expect(present).toEqual(['serial-1']) + }) + + it('keeps two emulators with the same serial behind different providers separate', () => { + const tracker = new PresenceTracker(TTL) + const present: string[] = [] + tracker.on('present', ({serial}) => present.push(serial)) + + tracker.bump('provider-a', 'emulator-5554', Date.now()) + tracker.bump('provider-b', 'emulator-5554', Date.now()) + + // both are distinct devices, so two present events for the same serial + expect(present).toEqual(['emulator-5554', 'emulator-5554']) + }) + + it('emits absent with the serial after the heartbeat TTL elapses', () => { + const tracker = new PresenceTracker(TTL) + const absent: string[] = [] + tracker.on('absent', ({serial}) => absent.push(serial)) + + tracker.bump('provider-a', 'serial-1', Date.now()) + vi.advanceTimersByTime(TTL + 1) + + expect(absent).toEqual(['serial-1']) + }) + + it('a bump within the TTL keeps the device present (no absent)', () => { + const tracker = new PresenceTracker(TTL) + const absent: string[] = [] + tracker.on('absent', ({serial}) => absent.push(serial)) + + tracker.bump('provider-a', 'serial-1', Date.now()) + vi.advanceTimersByTime(TTL - 1) + tracker.bump('provider-a', 'serial-1', Date.now()) + vi.advanceTimersByTime(TTL - 1) + + expect(absent).toEqual([]) + }) + + it('a silent bump (initial load) does not emit present', () => { + const tracker = new PresenceTracker(TTL) + const present: string[] = [] + tracker.on('present', ({serial}) => present.push(serial)) + + tracker.bump('provider-a', 'serial-1', Date.now(), {silent: true}) + + expect(present).toEqual([]) + }) + + it('re-introduction (drop then bump) re-emits present', () => { + const tracker = new PresenceTracker(TTL) + const present: string[] = [] + tracker.on('present', ({serial}) => present.push(serial)) + + tracker.bump('provider-a', 'serial-1', Date.now()) + // an introduction drops silently then bumps, mirroring the reaper flow + tracker.introduce('provider-a', 'serial-1', Date.now()) + + expect(present).toEqual(['serial-1', 'serial-1']) + }) +}) diff --git a/test/unit/units/processor/routing.test.ts b/test/unit/units/processor/routing.test.ts new file mode 100644 index 0000000000..4ef92e2e7b --- /dev/null +++ b/test/unit/units/processor/routing.test.ts @@ -0,0 +1,206 @@ +import {describe, it, expect} from 'vitest' +import {ProcessorRouting} from '../../../../lib/units/processor/routing.ts' +import {KIND, deviceKey} from '../../../../lib/wire/frame.ts' + +// Pure routing logic for the processor (no ZMQ I/O). The processor has two +// inputs with different framing: +// proxy DEALER: [kind, ...] (identity already stripped) +// device ROUTER: [deviceKeyId, kind, ...] (identity prepended) +// Two entry points, each returning Send[] tagged with the target channel +// (via 'router' -> devices, via 'dealer' -> proxy, via 'consume' -> local). + +const f = (...parts: string[]) => parts.map(p => Buffer.from(p)) +const str = (frames: Buffer[]) => frames.map(b => b.toString()) + +describe('ProcessorRouting: device-directed down (from proxy DEALER)', () => { + it('routes D to the device by deviceKey, forwarding the reply-path unchanged', () => { + const routing = new ProcessorRouting() + // from proxy DEALER (no identity frame): [D, providerName, serial, api-1, body] + const inbound = f(KIND.DEVICE, 'provider-a', 'serial-1', 'api-1', 'body') + + const sends = routing.routeFromProxy(inbound) + + expect(sends).toHaveLength(1) + expect(sends[0].via).toBe('router') + expect(sends[0].target!.toString()).toBe(deviceKey('provider-a', 'serial-1')) + // reply-path forwarded as-is (processor does not push its own identity) + expect(str(sends[0].frames)).toEqual([KIND.DEVICE, 'provider-a', 'serial-1', 'api-1', 'body']) + }) + + it('surfaces the proxy INIT timestamp for the startup presence sweep', () => { + const routing = new ProcessorRouting() + + const sends = routing.routeFromProxy(f(KIND.INIT, '1700000000123')) + + expect(sends).toHaveLength(1) + expect(sends[0].via).toBe('init') + expect(sends[0].target).toBeUndefined() + expect(str(sends[0].frames)).toEqual(['1700000000123']) + }) + + it('never mistakes an INIT for a device command', () => { + const routing = new ProcessorRouting() + + const sends = routing.routeFromProxy(f(KIND.INIT, '1700000000123')) + + // an 'init' must not be pushed at a device: frames[1]/[2] of an INIT are + // not providerName/serial, so a mis-tag would address a bogus deviceKey. + expect(sends.every(s => s.via !== 'router')).toBe(true) + }) + + it('does not forward an INIT back up to the proxy', () => { + const routing = new ProcessorRouting() + + const sends = routing.routeFromProxy(f(KIND.INIT, '1')) + + expect(sends.every(s => s.via !== 'dealer')).toBe(true) + }) + + it('leaves the timestamp frame verbatim for the glue to parse', () => { + const routing = new ProcessorRouting() + + // routing must not coerce, validate or reformat — that is the unit's job. + expect(str(routing.routeFromProxy(f(KIND.INIT, 'not-a-number'))[0].frames)) + .toEqual(['not-a-number']) + expect(str(routing.routeFromProxy(f(KIND.INIT, ''))[0].frames)).toEqual(['']) + }) + + it('ignores an INIT arriving from a DEVICE (wrong direction)', () => { + const routing = new ProcessorRouting() + const devId = deviceKey('provider-a', 'serial-1') + + // Devices only ever speak R or E; an I from below is silently dropped. + expect(routing.routeFromDevice(f(devId, KIND.INIT, '123'))).toEqual([]) + }) + + it('ignores a HELLO arriving from the proxy (wrong direction)', () => { + const routing = new ProcessorRouting() + + // HELLO is processor->proxy only; it must never be interpreted inbound. + expect(routing.routeFromProxy(f(KIND.HELLO))).toEqual([]) + }) + + it('does not learn a provider as a side effect of an INIT', () => { + const routing = new ProcessorRouting() + + routing.routeFromProxy(f(KIND.INIT, '1700000000123')) + + expect(routing.announceFrames()).toEqual([]) + }) + + it('still routes a normal device command after an INIT', () => { + const routing = new ProcessorRouting() + routing.routeFromProxy(f(KIND.INIT, '1700000000123')) + + const sends = routing.routeFromProxy(f(KIND.DEVICE, 'provider-a', 'serial-1', 'api-1', 'body')) + + expect(sends[0].via).toBe('router') + expect(sends[0].target!.toString()).toBe(deviceKey('provider-a', 'serial-1')) + }) +}) + +describe('ProcessorRouting: learnProvider announces exactly once per provider', () => { + it('reports true only on the first sighting, so heartbeats do not re-announce', () => { + const routing = new ProcessorRouting() + + // Truthy repeat would spam the proxy with redundant ANNOUNCE frames. + expect(routing.learnProvider('provider-a')).toBe(true) + expect(routing.learnProvider('provider-a')).toBe(false) + expect(routing.learnProvider('provider-a')).toBe(false) + }) + + it('tracks each provider independently', () => { + const routing = new ProcessorRouting() + + expect(routing.learnProvider('provider-a')).toBe(true) + expect(routing.learnProvider('provider-b')).toBe(true) + expect(routing.learnProvider('provider-a')).toBe(false) + expect(routing.learnProvider('provider-b')).toBe(false) + }) + + it('keeps every learned provider in the bulk re-announce (proxy restart path)', () => { + // This bulk replay repairs a restarted proxy's empty routing table. + const routing = new ProcessorRouting() + routing.learnProvider('provider-a') + routing.learnProvider('provider-b') + routing.learnProvider('provider-a') + + expect(routing.announceFrames().map(s => str(s.frames)).sort()) + .toEqual([[KIND.ANNOUNCE, 'provider-a'], [KIND.ANNOUNCE, 'provider-b']]) + }) + + it('re-announces idempotently: the bulk replay can be run repeatedly', () => { + const routing = new ProcessorRouting() + routing.learnProvider('provider-a') + + expect(routing.announceFrames()).toEqual(routing.announceFrames()) + }) +}) + +describe('ProcessorRouting: reply up (from device ROUTER)', () => { + it('forwards an R reply up to the proxy, stripping the ROUTER-added device identity', () => { + const routing = new ProcessorRouting() + // from a device over the ROUTER: [deviceKeyId, R, api-1, body] + const devId = deviceKey('provider-a', 'serial-1') + const inbound = f(devId, KIND.REPLY, 'api-1', 'reply-body') + + const sends = routing.routeFromDevice(inbound) + + expect(sends).toHaveLength(1) + expect(sends[0].via).toBe('dealer') + expect(sends[0].target).toBeUndefined() + // identity stripped; reply-path forwarded up unchanged + expect(str(sends[0].frames)).toEqual([KIND.REPLY, 'api-1', 'reply-body']) + }) +}) + +describe('ProcessorRouting: device event (from device ROUTER)', () => { + it('surfaces an E event for local consumption, keeping the sender identity and body', () => { + const routing = new ProcessorRouting() + // from a device over the ROUTER: [deviceKeyId, E, body] + const devId = deviceKey('provider-a', 'serial-1') + const inbound = f(devId, KIND.EVENT, 'body') + + const sends = routing.routeFromDevice(inbound) + + expect(sends).toHaveLength(1) + // 'consume': glue decodes + dispatches locally; message type determines + // whether to call dbapi or forward to app. + expect(sends[0].via).toBe('consume') + // sender identity preserved so a reply can be addressed back + expect(sends[0].sender!.toString()).toBe(devId) + // raw Envelope body handed over untouched + expect(sends[0].frames).toHaveLength(1) + expect(sends[0].frames[0].toString()).toBe('body') + }) + + it('drops an unknown device kind', () => { + const routing = new ProcessorRouting() + const devId = deviceKey('provider-a', 'serial-1') + const inbound = f(devId, 'Z', 'body') + + expect(routing.routeFromDevice(inbound)).toEqual([]) + }) +}) + +describe('ProcessorRouting: announce providers up to the proxy', () => { + it('emits one ANNOUNCE frame per learned provider (for (re)connect)', () => { + const routing = new ProcessorRouting() + routing.learnProvider('provider-a') + routing.learnProvider('provider-b') + // learning the same provider twice must not duplicate the announce + routing.learnProvider('provider-a') + + const sends = routing.announceFrames() + + expect(sends.every(s => s.via === 'dealer')).toBe(true) + expect(sends.every(s => s.target === undefined)).toBe(true) + expect(sends.map(s => str(s.frames)).sort()) + .toEqual([[KIND.ANNOUNCE, 'provider-a'], [KIND.ANNOUNCE, 'provider-b']]) + }) + + it('has nothing to announce before any provider is learned', () => { + const routing = new ProcessorRouting() + expect(routing.announceFrames()).toEqual([]) + }) +}) diff --git a/test/unit/units/processor/startup-sweep.test.ts b/test/unit/units/processor/startup-sweep.test.ts new file mode 100644 index 0000000000..fd95fca859 --- /dev/null +++ b/test/unit/units/processor/startup-sweep.test.ts @@ -0,0 +1,403 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' + +// The startup presence reconciliation sweep, driven end-to-end over real ZMQ +// sockets with Mongo mocked out. +// +// The behaviour under test lives inside the processor unit's closure, so it is +// exercised the way production does: a stand-in proxy ROUTER waits for the +// processor's one-shot HELLO and answers with an INIT carrying a chosen +// timestamp. Everything else (the presence TTL, the re-check query, the absent +// broadcasts) is then observed through the mocked db calls and the frames that +// arrive back at the proxy. +// +// Real timers are used deliberately: ZMQ's receive loop needs them, so the +// heartbeat timeout is set to a few hundred ms instead of being faked. + +const loadDevicesPresentBefore = vi.fn() +const setDeviceAbsent = vi.fn(async () => ({})) +const setDevicePresent = vi.fn(async () => ({})) + +vi.mock('../../../../lib/db/index.js', () => ({ + default: { + // Strip the connectivity wrapper: no Mongo in this test. + ensureConnectivity: (fn: any) => fn, + connect: async () => ({}), + }, +})) + +vi.mock('../../../../lib/db/models/device/index.js', () => ({ + default: { + loadDevicesPresentBefore, + loadPresentDevices: vi.fn(async () => []), + getDeadDevice: vi.fn(async () => []), + loadDeviceBySerial: vi.fn(async () => null), + deleteDevice: vi.fn(async () => ({})), + }, +})) + +vi.mock('../../../../lib/db/models/all/index.js', () => ({ + default: { + setDeviceAbsent, + setDevicePresent, + saveDeviceInitialState: vi.fn(async () => ({})), + saveIosDeviceInitialState: vi.fn(async () => ({})), + initializeIosDeviceState: vi.fn(async () => ({})), + saveDeviceStatus: vi.fn(async () => ({})), + setDeviceReady: vi.fn(async () => ({})), + sendEvent: vi.fn(async () => ({})), + setDeviceState: vi.fn(async () => ({})), + }, +})) + +vi.mock('../../../../lib/db/models/user/index.js', () => ({ + default: {lookupUserByAdbFingerprint: vi.fn(async () => null)}, +})) + +const {RouterSocket, DealerSocket} = await import('../../../../lib/util/zmqsocket.ts') +const {KIND, encodeInit, encodeEvent, deviceKey} = await import('../../../../lib/wire/frame.ts') +const {Envelope, DeviceHeartbeatMessage, DeviceAbsentMessage, DevicePresentMessage} = + await import('../../../../lib/wire/wire.ts') +const {Any} = await import('../../../../lib/wire/google/protobuf/any.ts') +const wireutil = (await import('../../../../lib/wire/util.js')).default +const processorUnit = (await import('../../../../lib/units/processor/index.ts')).default + +const nextPort = (() => { + let p = 16400 + return () => p++ +})() + +const HEARTBEAT_TIMEOUT = 250 + +const ABSENT_URL = Any.typeNameToUrl(DeviceAbsentMessage.typeName) +const PRESENT_URL = Any.typeNameToUrl(DevicePresentMessage.typeName) + +// A device row as projected by loadDevicesPresentBefore. +const row = (providerName: string, serial: string) => + ({serial, provider: {name: providerName}}) + +interface Harness { + proxy: InstanceType + processorId: Buffer + // Broadcast envelopes ([B, selector, envelope]) decoded by type url. + broadcastsOf: (typeUrl: string) => any[] + sendInit: (startedAt: number) => Promise + heartbeat: (providerName: string, serial: string) => Promise + deviceRouterAddr: string +} + +describe('processor: startup presence reconciliation sweep', () => { + const closing: Array<{close: () => any}> = [] + + beforeEach(() => { + loadDevicesPresentBefore.mockReset() + setDeviceAbsent.mockReset() + setDevicePresent.mockReset() + setDeviceAbsent.mockImplementation(async () => ({})) + setDevicePresent.mockImplementation(async () => ({})) + }) + + afterEach(async () => { + await Promise.all(closing.splice(0).map(s => Promise.resolve(s.close()).catch(() => {}))) + vi.clearAllMocks() + }) + + // Boot a stand-in proxy plus a real processor, and wait for the HELLO. + const boot = async (name = 'processor-1'): Promise => { + const proxyAddr = `tcp://127.0.0.1:${nextPort()}` + const deviceRouterAddr = `tcp://127.0.0.1:${nextPort()}` + + const proxy = new RouterSocket() + await proxy.bind(proxyAddr) + closing.push(proxy) + + const inbound: Buffer[][] = [] + let helloFrom: Buffer | null = null + proxy.on('frames', (frames: Buffer[]) => { + inbound.push(frames) + if (frames[1]?.toString() === KIND.HELLO) { + helloFrom ??= frames[0] + } + }) + + const unit = await processorUnit({ + name, + endpoints: {proxy: [proxyAddr], deviceRouter: deviceRouterAddr}, + heartbeatTimeout: HEARTBEAT_TIMEOUT, + publicIp: '127.0.0.1', + } as any) + // Tear the processor down between tests: a leaked instance keeps its + // presence TTL timers alive and would fire absent reaps into the next + // test's mocks. + closing.push({close: () => unit.shutdown()}) + + // The HELLO is the handshake the whole mechanism hangs off. + await vi.waitFor(() => expect(helloFrom).not.toBeNull(), {timeout: 2000, interval: 10}) + + const devices: Array> = [] + + return { + proxy, + processorId: helloFrom!, + deviceRouterAddr, + broadcastsOf: (typeUrl) => inbound + .filter(f => f[1]?.toString() === KIND.BROADCAST) + .map(f => Envelope.fromBinary(f[f.length - 1])) + .filter(e => e.message?.typeUrl === typeUrl), + sendInit: async (startedAt) => { + await proxy.send([helloFrom!, ...encodeInit(startedAt)]) + }, + heartbeat: async (providerName, serial) => { + const dealer = new DealerSocket({ + routingId: deviceKey(providerName, serial), + probeRouter: true, + }) + dealer.connect(deviceRouterAddr) + devices.push(dealer) + closing.push(dealer) + await dealer.send(encodeEvent( + Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial})))) + }, + } + } + + it('sends exactly one HELLO on startup', async () => { + const h = await boot() + const hellos: Buffer[][] = [] + h.proxy.on('frames', (f: Buffer[]) => { + if (f[1]?.toString() === KIND.HELLO) { + hellos.push(f) + } + }) + + await new Promise(r => setTimeout(r, 200)) + + // The one HELLO was already consumed by boot(); no further ones follow. + expect(hellos).toHaveLength(0) + }) + + it('queries devices present before its own boot when the proxy is OLDER', async () => { + loadDevicesPresentBefore.mockResolvedValue([]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + + await vi.waitFor(() => expect(loadDevicesPresentBefore).toHaveBeenCalledTimes(1), + {timeout: 2000, interval: 10}) + + // The cutoff must be the PROCESSOR's start time, as a Date for Mongo. + const cutoff = loadDevicesPresentBefore.mock.calls[0][0] + expect(cutoff).toBeInstanceOf(Date) + expect(cutoff.getTime()).toBeLessThanOrEqual(Date.now()) + expect(cutoff.getTime()).toBeGreaterThan(Date.now() - 30_000) + }) + + it('does NOTHING when the proxy is YOUNGER (the proxy, not us, restarted)', async () => { + loadDevicesPresentBefore.mockResolvedValue([row('provider-a', 'serial-1')]) + const h = await boot() + + await h.sendInit(Date.now() + 60_000) + await new Promise(r => setTimeout(r, HEARTBEAT_TIMEOUT * 2)) + + expect(loadDevicesPresentBefore).not.toHaveBeenCalled() + expect(setDeviceAbsent).not.toHaveBeenCalled() + }) + + it('treats an equal timestamp as not-older (no sweep)', async () => { + loadDevicesPresentBefore.mockResolvedValue([row('provider-a', 'serial-1')]) + const h = await boot() + + // Same millisecond as our boot: we did not outlive the proxy. + await h.sendInit(Date.now()) + await new Promise(r => setTimeout(r, HEARTBEAT_TIMEOUT * 2)) + + expect(setDeviceAbsent).not.toHaveBeenCalled() + }) + + it('does not broadcast a bogus present for the devices it merely watches', async () => { + loadDevicesPresentBefore.mockResolvedValue([ + row('provider-a', 'serial-1'), + row('provider-a', 'serial-2'), + ]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + await vi.waitFor(() => expect(loadDevicesPresentBefore).toHaveBeenCalled(), + {timeout: 2000, interval: 10}) + await new Promise(r => setTimeout(r, 80)) + + // Seeding is silent: we are not claiming these devices. + expect(h.broadcastsOf(PRESENT_URL)).toEqual([]) + }) + + it('marks a still-stale device absent once its TTL expires, and persists it', async () => { + loadDevicesPresentBefore.mockResolvedValue([row('provider-a', 'serial-1')]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + + await vi.waitFor(() => expect(setDeviceAbsent).toHaveBeenCalledTimes(1), + {timeout: HEARTBEAT_TIMEOUT * 8, interval: 10}) + + const [serial, presenceChangedAt] = setDeviceAbsent.mock.calls[0] as any[] + expect(serial).toBe('serial-1') + // The CAS guard in setDeviceAbsent needs a real epoch-millis timestamp. + expect(typeof presenceChangedAt).toBe('number') + expect(presenceChangedAt).toBeGreaterThan(0) + + // ...and the app side is told, so websocket clients drop the device. + await vi.waitFor(() => expect(h.broadcastsOf(ABSENT_URL)).toHaveLength(1), + {timeout: 2000, interval: 10}) + const absent = Any.unpack(h.broadcastsOf(ABSENT_URL)[0].message!, DeviceAbsentMessage) + expect(absent.serial).toBe('serial-1') + expect(absent.presenceChangedAt).toBe(presenceChangedAt) + }) + + it('RE-QUERIES Mongo on expiry and spares a device another processor adopted', async () => { + // Seeded with two devices; by the time the TTL expires, serial-2 has been + // adopted elsewhere (its introduction bumped presenceChangedAt past our + // cutoff), so it is no longer returned by the re-check. + loadDevicesPresentBefore + .mockResolvedValueOnce([row('provider-a', 'serial-1'), row('provider-a', 'serial-2')]) + .mockResolvedValue([row('provider-a', 'serial-1')]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + + await vi.waitFor(() => expect(setDeviceAbsent).toHaveBeenCalledTimes(1), + {timeout: HEARTBEAT_TIMEOUT * 8, interval: 10}) + await new Promise(r => setTimeout(r, 120)) + + // The re-check happened (seed + verify), and only the still-stale one died. + expect(loadDevicesPresentBefore.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(setDeviceAbsent.mock.calls.map((c: any[]) => c[0])).toEqual(['serial-1']) + }) + + it('marks nothing absent when every seeded device was adopted meanwhile', async () => { + loadDevicesPresentBefore + .mockResolvedValueOnce([row('provider-a', 'serial-1'), row('provider-a', 'serial-2')]) + .mockResolvedValue([]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + await new Promise(r => setTimeout(r, HEARTBEAT_TIMEOUT * 4)) + + expect(loadDevicesPresentBefore.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(setDeviceAbsent).not.toHaveBeenCalled() + }) + + it('batches N expiries into a SINGLE re-check query', async () => { + const many = Array.from({length: 25}, (_, i) => row('provider-a', `serial-${i}`)) + loadDevicesPresentBefore.mockResolvedValue(many) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + + await vi.waitFor(() => expect(setDeviceAbsent).toHaveBeenCalledTimes(25), + {timeout: HEARTBEAT_TIMEOUT * 10, interval: 10}) + await new Promise(r => setTimeout(r, 150)) + + // 1 seed + 1 verify. Without batching this would be 1 + 25. + expect(loadDevicesPresentBefore).toHaveBeenCalledTimes(2) + }) + + it('promotes a device that heartbeats to US off the sweep list entirely', async () => { + // Mongo keeps reporting BOTH as stale, so the only thing that can spare + // serial-live from the swept path is the heartbeat having removed it from + // the sweep set. + // + // Both paths write setDeviceAbsent to Mongo (presence: false must always + // be persisted). What distinguishes the swept path is the extra + // loadDevicesPresentBefore re-check — 1 seed query + 1 re-check = 2 calls + // for serial-dead, while serial-live (promoted off sweptKeys by its + // heartbeat) takes the ordinary reap path that never re-queries Mongo. + loadDevicesPresentBefore.mockResolvedValue([ + row('provider-a', 'serial-live'), + row('provider-a', 'serial-dead'), + ]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + await vi.waitFor(() => expect(loadDevicesPresentBefore).toHaveBeenCalled(), + {timeout: 2000, interval: 10}) + + // A live device talks to us, refreshing its TTL from `now`. + await h.heartbeat('provider-a', 'serial-live') + + // Wait until the processor has actually processed the heartbeat (presence + // bumped) before relying on the TTL gap between the two devices. + await vi.waitFor(() => expect(h.broadcastsOf(PRESENT_URL).length).toBeGreaterThanOrEqual(1), + {timeout: HEARTBEAT_TIMEOUT * 4, interval: 10}) + + // Wait past BOTH expiries: serial-dead at startedAt+TTL, and serial-live + // one TTL after its heartbeat. + await vi.waitFor(() => expect(h.broadcastsOf(ABSENT_URL).length).toBeGreaterThanOrEqual(2), + {timeout: HEARTBEAT_TIMEOUT * 12, interval: 10}) + + // Both were eventually reaped for lack of heartbeats and both wrote to Mongo. + const reaped = h.broadcastsOf(ABSENT_URL) + .map(e => Any.unpack(e.message!, DeviceAbsentMessage).serial) + expect(reaped.sort()).toEqual(['serial-dead', 'serial-live']) + + // serial-dead triggered the swept re-check path: 1 seed + 1 re-check = 2. + // serial-live was promoted off sweptKeys by its heartbeat and took the + // ordinary reap path, which does not call loadDevicesPresentBefore again. + expect(loadDevicesPresentBefore).toHaveBeenCalledTimes(2) + }) + + it('runs the sweep only once even if the proxy sends INIT repeatedly', async () => { + loadDevicesPresentBefore.mockResolvedValue([]) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + await vi.waitFor(() => expect(loadDevicesPresentBefore).toHaveBeenCalledTimes(1), + {timeout: 2000, interval: 10}) + + await h.sendInit(Date.now() - 60_000) + await h.sendInit(Date.now() - 120_000) + await new Promise(r => setTimeout(r, 200)) + + expect(loadDevicesPresentBefore).toHaveBeenCalledTimes(1) + }) + + it('survives a malformed INIT timestamp without sweeping or crashing', async () => { + loadDevicesPresentBefore.mockResolvedValue([row('provider-a', 'serial-1')]) + const h = await boot() + + await h.proxy.send([h.processorId, Buffer.from(KIND.INIT), Buffer.from('not-a-number')]) + await new Promise(r => setTimeout(r, HEARTBEAT_TIMEOUT * 2)) + + expect(loadDevicesPresentBefore).not.toHaveBeenCalled() + expect(setDeviceAbsent).not.toHaveBeenCalled() + + // The link is still healthy: a valid INIT afterwards still sweeps. (The + // call count then grows past 1, because the seeded device's TTL expiry + // triggers the legitimate re-check query.) + await h.sendInit(Date.now() - 60_000) + await vi.waitFor(() => expect(setDeviceAbsent).toHaveBeenCalledTimes(1), + {timeout: HEARTBEAT_TIMEOUT * 8, interval: 10}) + expect((setDeviceAbsent.mock.calls[0] as any[])[0]).toBe('serial-1') + }) + + it('keeps working when the re-check query fails', async () => { + loadDevicesPresentBefore + .mockResolvedValueOnce([row('provider-a', 'serial-1')]) + .mockRejectedValue(new Error('mongo down')) + const h = await boot() + + await h.sendInit(Date.now() - 60_000) + await new Promise(r => setTimeout(r, HEARTBEAT_TIMEOUT * 4)) + + // The failure is swallowed and logged; nothing is wrongly marked absent. + expect(setDeviceAbsent).not.toHaveBeenCalled() + }) + + it('does not sweep at all if the proxy never sends an INIT', async () => { + loadDevicesPresentBefore.mockResolvedValue([row('provider-a', 'serial-1')]) + await boot() + + await new Promise(r => setTimeout(r, HEARTBEAT_TIMEOUT * 3)) + + expect(loadDevicesPresentBefore).not.toHaveBeenCalled() + expect(setDeviceAbsent).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/units/proxy/index.test.ts b/test/unit/units/proxy/index.test.ts new file mode 100644 index 0000000000..490f709fef --- /dev/null +++ b/test/unit/units/proxy/index.test.ts @@ -0,0 +1,59 @@ +import {describe, it, expect, afterEach} from 'vitest' +import proxy from '../../../../lib/units/proxy/index.ts' +import {DealerSocket} from '../../../../lib/util/zmqsocket.ts' +import {KIND} from '../../../../lib/wire/frame.ts' + +// End-to-end integration of the proxy unit over real in-process sockets. + +const nextPort = (() => { + let p = 15800 + return () => p++ +})() + +const nextFrames = (sock: DealerSocket): Promise => + new Promise((resolve) => sock.once('frames', resolve)) + +const str = (frames: Buffer[]) => frames.map(b => b.toString()) + +describe('proxy unit end-to-end', () => { + const open: Array<{close: () => Promise | void}> = [] + afterEach(async () => { + await Promise.all(open.splice(0).map(s => Promise.resolve(s.close()).catch(() => {}))) + }) + + it('routes a device command from api to the owning processor and the reply back', async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + const {router} = await proxy({endpoints: {router: addr}}) + open.push(router) + + const processor = new DealerSocket({routingId: 'processor-1'}) + processor.connect(addr) + open.push(processor) + + const api = new DealerSocket({routingId: 'api-1'}) + api.connect(addr) + open.push(api) + + // processor announces ownership of provider-a + await processor.send([Buffer.from(KIND.ANNOUNCE), Buffer.from('provider-a')]) + // give the announce time to be processed by the proxy loop + await new Promise(r => setTimeout(r, 50)) + + // api sends a device command + const atProcessor = nextFrames(processor) + await api.send([Buffer.from(KIND.DEVICE), Buffer.from('provider-a'), Buffer.from('serial-1'), Buffer.from('cmd')]) + + const received = await atProcessor + // proxy pushed api's identity onto the reply-path + expect(str(received)).toEqual([KIND.DEVICE, 'provider-a', 'serial-1', 'api-1', 'cmd']) + + // processor replies: pop tail is done by the proxy; processor sends + // [R, api-1, reply] (its own reply-path minus itself already handled by + // device layer; here we simulate the frames arriving at the proxy). + const atApi = nextFrames(api) + await processor.send([Buffer.from(KIND.REPLY), Buffer.from('api-1'), Buffer.from('reply')]) + + const back = await atApi + expect(str(back)).toEqual([KIND.REPLY, 'reply']) + }) +}) diff --git a/test/unit/units/proxy/routing.test.ts b/test/unit/units/proxy/routing.test.ts new file mode 100644 index 0000000000..4162b1467c --- /dev/null +++ b/test/unit/units/proxy/routing.test.ts @@ -0,0 +1,240 @@ +import {describe, it, expect} from 'vitest' +import {ProxyRouting} from '../../../../lib/units/proxy/routing.ts' +import {KIND} from '../../../../lib/wire/frame.ts' + +// Pure routing logic for the proxy: given an inbound multipart (sender identity +// at frame 0) and the proxy's routing state, decide the outbound sends. No ZMQ. + +const f = (...parts: string[]) => parts.map(p => Buffer.from(p)) +const str = (frames: Buffer[]) => frames.map(b => b.toString()) + +describe('ProxyRouting: device-directed (D)', () => { + it('routes a D message to the processor owning the providerName, pushing the sender onto the reply-path', () => { + const routing = new ProxyRouting() + routing.announceProvider('provider-a', Buffer.from('processor-1')) + + // api (identity "api-1") sends: [api-1, D, provider-a, serial-1, body] + const inbound = f('api-1', KIND.DEVICE, 'provider-a', 'serial-1', 'body') + + const sends = routing.route(inbound) + + expect(sends).toHaveLength(1) + expect(sends[0].target.toString()).toBe('processor-1') + // reply-path gains the sender (api-1) just before the body + expect(str(sends[0].frames)) + .toEqual([KIND.DEVICE, 'provider-a', 'serial-1', 'api-1', 'body']) + }) + + it('drops a D message for an unknown provider (no processor yet)', () => { + const routing = new ProxyRouting() + const inbound = f('api-1', KIND.DEVICE, 'ghost-provider', 'serial-1', 'body') + + const sends = routing.route(inbound) + + expect(sends).toEqual([]) + }) +}) + +describe('ProxyRouting: reply (R)', () => { + it('pops the top reply-path id and sends the reply there', () => { + const routing = new ProxyRouting() + // A processor sends back a reply. Its ROUTER-view inbound is: + // [processorId, R, api-1, body] (api-1 is the remaining reply-path) + const inbound = f('processor-1', KIND.REPLY, 'api-1', 'reply-body') + + const sends = routing.route(inbound) + + expect(sends).toHaveLength(1) + expect(sends[0].target.toString()).toBe('api-1') + // reply-path now empty -> only [R, body] forwarded to the final recipient + expect(str(sends[0].frames)).toEqual([KIND.REPLY, 'reply-body']) + }) +}) + +describe('ProxyRouting: broadcast (B)', () => { + it('fans out only to registered broadcast receivers', () => { + const routing = new ProxyRouting() + routing.registerBroadcastReceiver(Buffer.from('websocket-1')) + routing.registerBroadcastReceiver(Buffer.from('log-1')) + + // A processor emits an event: [processorId, B, selector, body] + const inbound = f('processor-1', KIND.BROADCAST, '', 'event-body') + const sends = routing.route(inbound) + + expect(sends.map(s => s.target.toString()).sort()) + .toEqual(['log-1', 'websocket-1']) + // Each receiver gets the message minus the sender identity. + for (const s of sends) { + expect(str(s.frames)).toEqual([KIND.BROADCAST, '', 'event-body']) + } + }) + + it('does not fan out to a unit that never registered (e.g. api)', () => { + const routing = new ProxyRouting() + routing.registerBroadcastReceiver(Buffer.from('websocket-1')) + + const inbound = f('processor-1', KIND.BROADCAST, '', 'event-body') + const sends = routing.route(inbound) + + expect(sends.map(s => s.target.toString())).toEqual(['websocket-1']) + }) + + it('stops sending to a receiver after it is unregistered', () => { + const routing = new ProxyRouting() + const ws = Buffer.from('websocket-1') + routing.registerBroadcastReceiver(ws) + routing.unregisterBroadcastReceiver(ws) + + const inbound = f('processor-1', KIND.BROADCAST, '', 'event-body') + expect(routing.route(inbound)).toEqual([]) + }) +}) + +describe('ProxyRouting: control plane over the wire', () => { + it('learns providerName->processor from an ANNOUNCE control frame', () => { + const routing = new ProxyRouting() + + // processor announces it owns provider-a: [processorId, A, provider-a] + const announce = f('processor-1', KIND.ANNOUNCE, 'provider-a') + expect(routing.route(announce)).toEqual([]) // control: no forward + + // now a D for provider-a routes to processor-1 + const d = f('api-1', KIND.DEVICE, 'provider-a', 'serial-1', 'body') + const sends = routing.route(d) + expect(sends[0].target.toString()).toBe('processor-1') + }) + + it('registers a broadcast receiver from a SUBSCRIBE control frame', () => { + const routing = new ProxyRouting() + + const sub = f('websocket-1', KIND.SUBSCRIBE_BROADCAST) + expect(routing.route(sub)).toEqual([]) + + const b = f('processor-1', KIND.BROADCAST, '', 'event-body') + const sends = routing.route(b) + expect(sends.map(s => s.target.toString())).toEqual(['websocket-1']) + }) +}) + +describe('ProxyRouting: one-shot startup INIT', () => { + it('answers the FIRST HELLO with [I, startedAt] addressed to that processor', () => { + const routing = new ProxyRouting(1700000000123) + + const sends = routing.route(f('processor-1', KIND.HELLO)) + + expect(sends).toHaveLength(1) + expect(sends[0].target.toString()).toBe('processor-1') + expect(str(sends[0].frames)).toEqual([KIND.INIT, '1700000000123']) + }) + + it('sends INIT to exactly one processor, ignoring every later HELLO', () => { + const routing = new ProxyRouting(1700000000123) + + expect(routing.route(f('processor-1', KIND.HELLO))).toHaveLength(1) + // a second, later-replicated processor gets nothing + expect(routing.route(f('processor-2', KIND.HELLO))).toEqual([]) + // and neither does the first one if it says hello again + expect(routing.route(f('processor-1', KIND.HELLO))).toEqual([]) + }) + + it('elects the FIRST processor even when many replicas hello in a burst', () => { + const routing = new ProxyRouting(1700000000123) + const replicas = ['processor-1', 'processor-2', 'processor-3', 'processor-4', 'processor-5'] + + const elected = replicas.flatMap(id => routing.route(f(id, KIND.HELLO))) + + expect(elected).toHaveLength(1) + expect(elected[0].target.toString()).toBe('processor-1') + }) + + it('stays latched across an arbitrary number of later hellos', () => { + const routing = new ProxyRouting(1700000000123) + routing.route(f('processor-1', KIND.HELLO)) + + for (let i = 0; i < 100; i++) { + expect(routing.route(f(`processor-${i}`, KIND.HELLO))).toEqual([]) + } + }) + + it('does not disturb normal routing: HELLO is not a provider announcement', () => { + const routing = new ProxyRouting(1700000000123) + routing.route(f('processor-1', KIND.HELLO)) + + // HELLO carries no providerName, so nothing is routable yet + expect(routing.route(f('api-1', KIND.DEVICE, 'provider-a', 'serial-1', 'body'))).toEqual([]) + + // the separate ANNOUNCE is still what builds the provider table + routing.route(f('processor-1', KIND.ANNOUNCE, 'provider-a')) + const sends = routing.route(f('api-1', KIND.DEVICE, 'provider-a', 'serial-1', 'body')) + expect(sends[0].target.toString()).toBe('processor-1') + }) + + it('elects on HELLO only — an ANNOUNCE must never trigger the INIT', () => { + // This is the whole point of a separate HELLO: ANNOUNCE arrives on every + // newly learned provider, so electing on it would be ambiguous. + const routing = new ProxyRouting(1700000000123) + + expect(routing.route(f('processor-1', KIND.ANNOUNCE, 'provider-a'))).toEqual([]) + expect(routing.route(f('processor-1', KIND.ANNOUNCE, 'provider-b'))).toEqual([]) + + // the election is still available for the real HELLO + expect(routing.route(f('processor-1', KIND.HELLO))).toHaveLength(1) + }) + + it('does not let an app-side SUBSCRIBE consume the election', () => { + const routing = new ProxyRouting(1700000000123) + + expect(routing.route(f('websocket-1', KIND.SUBSCRIBE_BROADCAST))).toEqual([]) + + const sends = routing.route(f('processor-1', KIND.HELLO)) + expect(sends[0].target.toString()).toBe('processor-1') + }) + + it('reports the timestamp it was constructed with, not the time of the hello', () => { + // The INIT must carry the PROXY START time. If it carried "now", a + // long-running proxy would look younger than a freshly booted processor + // and the reconciliation sweep would never run. + const startedAt = 1600000000000 + const routing = new ProxyRouting(startedAt) + + const sends = routing.route(f('processor-1', KIND.HELLO)) + + expect(sends[0].frames[1].toString()).toBe(String(startedAt)) + }) + + it('defaults startedAt to construction time when not supplied', () => { + const before = Date.now() + const routing = new ProxyRouting() + const after = Date.now() + + const sends = routing.route(f('processor-1', KIND.HELLO)) + const startedAt = Number(sends[0].frames[1].toString()) + + expect(startedAt).toBeGreaterThanOrEqual(before) + expect(startedAt).toBeLessThanOrEqual(after) + }) + + it('gives a fresh proxy instance its own election (per-proxy-lifetime, not global)', () => { + // Modelling a proxy restart: a new ProxyRouting is a new lifetime, so the + // one-shot budget resets and the processor that reconnects gets an INIT. + const first = new ProxyRouting(1000) + first.route(f('processor-1', KIND.HELLO)) + expect(first.route(f('processor-1', KIND.HELLO))).toEqual([]) + + const restarted = new ProxyRouting(2000) + const sends = restarted.route(f('processor-1', KIND.HELLO)) + + expect(sends).toHaveLength(1) + expect(str(sends[0].frames)).toEqual([KIND.INIT, '2000']) + }) + + it('keeps HELLO out of the broadcast fan-out', () => { + const routing = new ProxyRouting(1700000000123) + routing.route(f('websocket-1', KIND.SUBSCRIBE_BROADCAST)) + + const sends = routing.route(f('processor-1', KIND.HELLO)) + + // addressed to the processor that said hello, NOT to the subscribers + expect(sends.map(s => s.target.toString())).toEqual(['processor-1']) + }) +}) diff --git a/test/unit/units/websocket/broadcast.integration.test.ts b/test/unit/units/websocket/broadcast.integration.test.ts new file mode 100644 index 0000000000..61ef4b02f4 --- /dev/null +++ b/test/unit/units/websocket/broadcast.integration.test.ts @@ -0,0 +1,106 @@ +import {describe, it, expect, afterEach} from 'vitest' +import {RouterSocket, DealerSocket} from '../../../../lib/util/zmqsocket.ts' +import {AppTransport} from '../../../../lib/wire/app-transport.ts' +import {WireRouter} from '../../../../lib/wire/router.ts' +import {ProxyRouting} from '../../../../lib/units/proxy/routing.ts' +import {encodeBroadcast} from '../../../../lib/wire/frame.ts' +import {DeviceChangeMessage} from '../../../../lib/wire/wire.ts' +import wireutil from '../../../../lib/wire/util.ts' +import {ClientDispatcher} from '../../../../lib/units/websocket/support/clientDispatcher.ts' + +// Integration-style test: a broadcast published to a real proxy ROUTER is fanned +// out to a broadcast-receiver DEALER (as the websocket unit does), decoded ONCE +// by a single unit-wide WireRouter, and dispatched through ClientDispatcher to every +// registered connection. This proves the "one transport listener, per-socket +// dispatch" model end to end (no per-client WireRouter). + +const nextPort = (() => { + let p = 16300 + return () => p++ +})() + +const nextRealFrames = (sock: {on: (e: string, fn: (f: Buffer[]) => void) => void; removeListener: (e: string, fn: (f: Buffer[]) => void) => void}): Promise => + new Promise((resolve) => { + const onFrames = (frames: Buffer[]) => { + if (frames.length <= 1 || (frames.length === 2 && frames[1].length === 0)) { + return + } + sock.removeListener('frames', onFrames) + resolve(frames) + } + sock.on('frames', onFrames) + }) + +describe('websocket broadcast fan-out <-> proxy', () => { + const open: Array<{close: () => any}> = [] + afterEach(async () => { + await Promise.all(open.splice(0).map(s => Promise.resolve(s.close()).catch(() => {}))) + }) + + const setup = async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + const routing = new ProxyRouting() + const router = new RouterSocket() + router.on('frames', async (frames: Buffer[]) => { + for (const {target, frames: out} of routing.route(frames)) { + await router.send([target, ...out]).catch(() => {}) + } + }) + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'ws-1', probeRouter: true}) + dealer.connect(addr) + const transport = new AppTransport(dealer) + open.push(transport) + return {addr, router, routing, transport} + } + + it('delivers a broadcast to every registered connection through one WireRouter', async () => { + const {router, routing, transport} = await setup() + + // The unit builds ONE hub + ONE WireRouter for the whole process. + const hub = new ClientDispatcher() + const route = new WireRouter() + .on(DeviceChangeMessage, (ch, m) => hub.dispatch('DeviceChangeMessage', ch, m)) + .handler() + transport.on('broadcast', (body: Buffer) => route('', body)) + + // Two independent connections register their per-type handlers. + const seenA: any[] = [] + const seenB: any[] = [] + hub.add('a', {DeviceChangeMessage: (_ch, m) => seenA.push(m.device?.serial)}) + hub.add('b', {DeviceChangeMessage: (_ch, m) => seenB.push(m.device?.serial)}) + + const learn = nextRealFrames(router) + transport.registerBroadcast() + await learn + + const gotBoth = new Promise((resolve) => { + const check = () => { + if (seenA.length && seenB.length) { + resolve() + } + } + hub.add('probe', {DeviceChangeMessage: () => setImmediate(check)}) + }) + + // groups-engine/processor publishes a DeviceChange broadcast to the proxy. + const env = Buffer.from(wireutil.pack(DeviceChangeMessage, { + device: {serial: 'serial-42'}, + action: 'updated', + oldOriginGroupId: '', + timeStamp: 0, + } as any)) + for (const {target, frames} of routing.route([ + Buffer.from('source-1'), + ...encodeBroadcast(env), + ])) { + await router.send([target, ...frames]) + } + + await gotBoth + expect(seenA).toEqual(['serial-42']) + expect(seenB).toEqual(['serial-42']) + }) +}) diff --git a/test/unit/units/websocket/clientDispatcher.test.ts b/test/unit/units/websocket/clientDispatcher.test.ts new file mode 100644 index 0000000000..3a585940f3 --- /dev/null +++ b/test/unit/units/websocket/clientDispatcher.test.ts @@ -0,0 +1,70 @@ +import {describe, it, expect} from 'vitest' +import {ClientDispatcher} from '../../../../lib/units/websocket/support/clientDispatcher.ts' + +// The websocket unit must NOT create a WireRouter (and channelRouter listeners) +// per client — that leaks listeners. ClientDispatcher is the unit-wide fan-out: +// every connection registers a per-type handler map once; a single broadcast +// decode point calls dispatch(), which invokes each connection's handler for that type. +describe('ClientDispatcher', () => { + it('dispatches a broadcast to every connection subscribed to that type', () => { + const hub = new ClientDispatcher() + const seenA: any[] = [] + const seenB: any[] = [] + hub.add('a', {DeviceChange: (ch, msg) => seenA.push({ch, msg})}) + hub.add('b', {DeviceChange: (ch, msg) => seenB.push({ch, msg})}) + + hub.dispatch('DeviceChange', 'chan', {serial: 's1'}) + + expect(seenA).toEqual([{ch: 'chan', msg: {serial: 's1'}}]) + expect(seenB).toEqual([{ch: 'chan', msg: {serial: 's1'}}]) + }) + + it('only invokes handlers registered for the dispatched type', () => { + const hub = new ClientDispatcher() + let deviceChangeCalls = 0 + let userChangeCalls = 0 + hub.add('a', { + DeviceChange: () => deviceChangeCalls++, + UserChange: () => userChangeCalls++, + }) + + hub.dispatch('DeviceChange', '', {}) + + expect(deviceChangeCalls).toBe(1) + expect(userChangeCalls).toBe(0) + }) + + it('stops dispatching to a removed connection (no listener leak)', () => { + const hub = new ClientDispatcher() + let calls = 0 + hub.add('a', {DeviceChange: () => calls++}) + + hub.dispatch('DeviceChange', '', {}) + hub.remove('a') + hub.dispatch('DeviceChange', '', {}) + + expect(calls).toBe(1) + }) + + it('isolates a throwing handler so other connections still receive the event', () => { + const hub = new ClientDispatcher() + let bCalled = false + hub.add('a', {DeviceChange: () => { + throw new Error('boom') + }}) + hub.add('b', {DeviceChange: () => { + bCalled = true + }}) + + expect(() => hub.dispatch('DeviceChange', '', {})).not.toThrow() + expect(bCalled).toBe(true) + }) + + it('does nothing for a type no connection handles', () => { + const hub = new ClientDispatcher() + hub.add('a', {DeviceChange: () => { + throw new Error('should not be called') + }}) + expect(() => hub.dispatch('UnhandledType', '', {})).not.toThrow() + }) +}) diff --git a/test/unit/units/websocket/deviceOwnership.test.ts b/test/unit/units/websocket/deviceOwnership.test.ts new file mode 100644 index 0000000000..feb478de81 --- /dev/null +++ b/test/unit/units/websocket/deviceOwnership.test.ts @@ -0,0 +1,45 @@ +import {describe, it, expect} from 'vitest' +import {DeviceOwnership} from '../../../../lib/units/websocket/support/deviceOwnership.ts' + +// Under the ROUTER/DEALER model a websocket client addresses a device by its +// raw serial (not a sha1 channel). Each connection tracks which serials it owns +// and caches the provider.name needed to route commands to them. +describe('DeviceOwnership', () => { + it('is not owned until claimed', () => { + const own = new DeviceOwnership() + expect(own.isOwned('serial-1')).toBe(false) + }) + + it('claims a serial with its provider and reports ownership', () => { + const own = new DeviceOwnership() + own.claim('serial-1', 'provider-a') + expect(own.isOwned('serial-1')).toBe(true) + expect(own.providerOf('serial-1')).toBe('provider-a') + }) + + it('releases a claimed serial', () => { + const own = new DeviceOwnership() + own.claim('serial-1', 'provider-a') + own.release('serial-1') + expect(own.isOwned('serial-1')).toBe(false) + expect(own.providerOf('serial-1')).toBeUndefined() + }) + + it('keeps a provider hint without granting ownership (for lazy resolution)', () => { + const own = new DeviceOwnership() + own.rememberProvider('serial-1', 'provider-a') + // Remembering the provider must not imply ownership. + expect(own.isOwned('serial-1')).toBe(false) + expect(own.providerOf('serial-1')).toBe('provider-a') + }) + + it('tracks multiple serials independently', () => { + const own = new DeviceOwnership() + own.claim('s1', 'p1') + own.claim('s2', 'p2') + own.release('s1') + expect(own.isOwned('s1')).toBe(false) + expect(own.isOwned('s2')).toBe(true) + expect(own.providerOf('s2')).toBe('p2') + }) +}) diff --git a/test/unit/units/websocket/ownershipCache.test.ts b/test/unit/units/websocket/ownershipCache.test.ts new file mode 100644 index 0000000000..6936bdb40b --- /dev/null +++ b/test/unit/units/websocket/ownershipCache.test.ts @@ -0,0 +1,78 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' +import {OwnershipCache, type OwnedDevice} from '../../../../lib/units/websocket/support/ownershipCache.ts' + +const makeDevices = (...serials: string[]): OwnedDevice[] => + serials.map((serial, i) => ({serial, providerName: `provider-${i}`})) + +describe('OwnershipCache', () => { + let clock: number + + beforeEach(() => { + clock = Date.now() + vi.useFakeTimers() + vi.setSystemTime(clock) + }) + + it('returns loaded devices on first call', async () => { + const cache = new OwnershipCache(10_000) + const loader = vi.fn().mockResolvedValue(makeDevices('s1', 's2')) + + const result = await cache.get('user@a', loader) + expect(result).toEqual(makeDevices('s1', 's2')) + expect(loader).toHaveBeenCalledOnce() + }) + + it('serves from cache on second call within TTL (loader not called again)', async () => { + const cache = new OwnershipCache(10_000) + const loader = vi.fn().mockResolvedValue(makeDevices('s1')) + + await cache.get('user@a', loader) + vi.advanceTimersByTime(5_000) + const result = await cache.get('user@a', loader) + + expect(result).toEqual(makeDevices('s1')) + expect(loader).toHaveBeenCalledOnce() + }) + + it('calls loader again after TTL expires', async () => { + const cache = new OwnershipCache(10_000) + const loader = vi.fn() + .mockResolvedValueOnce(makeDevices('s1')) + .mockResolvedValueOnce(makeDevices('s1', 's2')) + + await cache.get('user@a', loader) + vi.advanceTimersByTime(10_001) + const result = await cache.get('user@a', loader) + + expect(result).toEqual(makeDevices('s1', 's2')) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('invalidate forces a fresh load on next call', async () => { + const cache = new OwnershipCache(10_000) + const loader = vi.fn() + .mockResolvedValueOnce(makeDevices('s1')) + .mockResolvedValueOnce([]) + + await cache.get('user@a', loader) + cache.invalidate('user@a') + const result = await cache.get('user@a', loader) + + expect(result).toEqual([]) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('keeps separate entries per email', async () => { + const cache = new OwnershipCache(10_000) + const loaderA = vi.fn().mockResolvedValue(makeDevices('s1')) + const loaderB = vi.fn().mockResolvedValue(makeDevices('s2')) + + const [a, b] = await Promise.all([ + cache.get('a@x', loaderA), + cache.get('b@x', loaderB), + ]) + + expect(a[0].serial).toBe('s1') + expect(b[0].serial).toBe('s2') + }) +}) diff --git a/test/unit/util/ttlset.test.ts b/test/unit/util/ttlset.test.ts new file mode 100644 index 0000000000..c7e4206ccf --- /dev/null +++ b/test/unit/util/ttlset.test.ts @@ -0,0 +1,260 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import TTLSet from '../../../lib/util/ttlset.ts' + +// TTLSet is an intrusive doubly-linked list ordered oldest-first, plus a single +// timer. check() walks from the head and stops at the first item that is still +// alive, so the ORDERING INVARIANT — head is always the oldest — is what makes +// expiry correct. Every re-bump must move the item to the tail; if a re-bumped +// item lands at the head instead, older items end up stranded behind it and are +// never dropped. + +describe('TTLSet', () => { + const TTL = 1000 + + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + const collectDrops = (set: TTLSet) => { + const dropped: string[] = [] + set.on('drop', (v: string) => dropped.push(v)) + return dropped + } + + it('emits insert for a new value only', () => { + const set = new TTLSet(TTL) + const inserted: string[] = [] + set.on('insert', (v: string) => inserted.push(v)) + + set.bump('a', Date.now()) + set.bump('a', Date.now()) + + expect(inserted).toEqual(['a']) + set.stop() + }) + + it('drops a value once its TTL elapses', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + vi.advanceTimersByTime(TTL + 1) + + expect(dropped).toEqual(['a']) + set.stop() + }) + + it('does not drop a value that keeps being bumped', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + for (let i = 0; i < 5; i++) { + vi.advanceTimersByTime(TTL - 10) + set.bump('a', Date.now()) + } + vi.advanceTimersByTime(TTL - 10) + + expect(dropped).toEqual([]) + set.stop() + }) + + // ---- ordering-invariant regression tests -------------------------------- + // + // These pin the bug where bump() re-linked a re-bumped item using its own + // already-nulled `prev`, making it the HEAD rather than the tail. + + it('still expires a silent value after ANOTHER value was re-bumped', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('silent', Date.now()) + set.bump('live', Date.now()) + // Re-bumping 'live' must not strand 'silent' behind it. + set.bump('live', Date.now()) + + vi.advanceTimersByTime(TTL + 1) + + expect(dropped.sort()).toEqual(['live', 'silent']) + set.stop() + }) + + it('expires every member of a large set where all but one were re-bumped', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + const values = Array.from({length: 25}, (_, i) => `d${i}`) + + values.forEach(v => set.bump(v, Date.now())) + // everyone except the last one heartbeats again + values.slice(0, -1).forEach(v => set.bump(v, Date.now())) + + vi.advanceTimersByTime(TTL + 1) + + expect(dropped.sort()).toEqual([...values].sort()) + set.stop() + }) + + it('reaps ONLY the silent value while another keeps heartbeating', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('live', Date.now()) + set.bump('silent', Date.now()) + + // 'live' heartbeats across the whole window; 'silent' never does. + for (let i = 0; i < 4; i++) { + vi.advanceTimersByTime(TTL / 2) + set.bump('live', Date.now()) + } + + expect(dropped).toEqual(['silent']) + set.stop() + }) + + it('drops in oldest-first order', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('first', Date.now()) + vi.advanceTimersByTime(10) + set.bump('second', Date.now()) + vi.advanceTimersByTime(10) + set.bump('third', Date.now()) + + vi.advanceTimersByTime(TTL + 100) + + expect(dropped).toEqual(['first', 'second', 'third']) + set.stop() + }) + + it('re-bumping the head moves it behind the remaining items', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + vi.advanceTimersByTime(10) + set.bump('b', Date.now()) + vi.advanceTimersByTime(10) + // 'a' is the head; re-bumping must send it to the tail + set.bump('a', Date.now()) + + vi.advanceTimersByTime(TTL + 100) + + expect(dropped).toEqual(['b', 'a']) + set.stop() + }) + + it('keeps working after the sole item is dropped and re-inserted', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + vi.advanceTimersByTime(TTL + 1) + expect(dropped).toEqual(['a']) + + // The list is now empty; inserting again must behave like a fresh set. + set.bump('a', Date.now()) + vi.advanceTimersByTime(TTL + 1) + + expect(dropped).toEqual(['a', 'a']) + set.stop() + }) + + it('an explicit drop removes the item without waiting for the TTL', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + set.bump('b', Date.now()) + set.drop('a') + + expect(dropped).toEqual(['a']) + + // and 'b' still expires normally afterwards + vi.advanceTimersByTime(TTL + 1) + expect(dropped).toEqual(['a', 'b']) + set.stop() + }) + + it('dropping the middle of the list leaves the neighbours expiring correctly', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + set.bump('b', Date.now()) + set.bump('c', Date.now()) + set.drop('b', TTLSet.SILENT) + + vi.advanceTimersByTime(TTL + 1) + + expect(dropped).toEqual(['a', 'c']) + set.stop() + }) + + it('a SILENT drop emits nothing', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + set.drop('a', TTLSet.SILENT) + + expect(dropped).toEqual([]) + set.stop() + }) + + it('a SILENT bump emits no insert but still expires', () => { + const set = new TTLSet(TTL) + const inserted: string[] = [] + set.on('insert', (v: string) => inserted.push(v)) + const dropped = collectDrops(set) + + set.bump('a', Date.now(), TTLSet.SILENT) + expect(inserted).toEqual([]) + + vi.advanceTimersByTime(TTL + 1) + expect(dropped).toEqual(['a']) + set.stop() + }) + + it('honours an explicitly supplied (older) timestamp', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + // Seeding with a past timestamp is how the processor arms a TTL for a + // device it has never heard from. + set.bump('a', Date.now() - TTL / 2) + vi.advanceTimersByTime(TTL / 2 + 10) + + expect(dropped).toEqual(['a']) + set.stop() + }) + + it('stop() prevents any further drops', () => { + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + + set.bump('a', Date.now()) + set.stop() + vi.advanceTimersByTime(TTL * 5) + + expect(dropped).toEqual([]) + }) + + it('drops many values seeded at the SAME timestamp in one pass', () => { + // This is exactly the processor's startup sweep shape: every seeded device + // shares time = startedAt, so they must all expire together. + const set = new TTLSet(TTL) + const dropped = collectDrops(set) + const seededAt = Date.now() + const values = Array.from({length: 30}, (_, i) => `s${i}`) + + values.forEach(v => set.bump(v, seededAt, TTLSet.SILENT)) + vi.advanceTimersByTime(TTL + 1) + + expect(dropped.sort()).toEqual([...values].sort()) + set.stop() + }) +}) diff --git a/test/unit/util/zmqsocket.test.ts b/test/unit/util/zmqsocket.test.ts new file mode 100644 index 0000000000..3fa2295716 --- /dev/null +++ b/test/unit/util/zmqsocket.test.ts @@ -0,0 +1,230 @@ +import {describe, it, expect, afterEach} from 'vitest' +import {RouterSocket, DealerSocket} from '../../../lib/util/zmqsocket.ts' + +// Integration-style tests over real in-process TCP sockets. This is the thin +// ROUTER/DEALER layer over native zeromq v6 (NOT the legacy v5-compatible +// SocketWrapper). We drive the real public interface, no ZMQ mocking. + +const nextPort = (() => { + let p = 15700 + return () => p++ +})() + +const nextFrames = (sock: {once: (e: string, fn: (f: Buffer[]) => void) => void}): Promise => + new Promise((resolve) => sock.once('frames', resolve)) + +describe('RouterSocket / DealerSocket', () => { + const open: Array<{close: () => Promise}> = [] + afterEach(async () => { + await Promise.all(open.splice(0).map(s => s.close().catch(() => {}))) + }) + + it('delivers a DEALER routingId as the first raw frame to the ROUTER', async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'provider-a\u0000serial-1'}) + dealer.connect(addr) + open.push(dealer) + + const received = nextFrames(router) + await dealer.send([Buffer.from('D'), Buffer.from('hello')]) + + const frames = await received + expect(frames[0].toString()).toBe('provider-a\u0000serial-1') + expect(frames[1].toString()).toBe('D') + expect(frames[2].toString()).toBe('hello') + }) + + it('does not emit reconnect for the very first connection', async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'processor-1', probeRouter: true}) + let reconnects = 0 + dealer.watchReconnect().on('reconnect', () => reconnects++) + dealer.connect(addr) + open.push(dealer) + + // Round-trip a message so we know the link is genuinely established. + await nextFrames(router) + + expect(reconnects).toBe(0) + }) + + it('emits reconnect once when the peer ROUTER restarts', async () => { + // The state a processor pushes to a proxy (ANNOUNCE/HELLO) is lost when the + // proxy restarts, because the new process has an empty routing table. ZMQ + // reconnects transparently and does NOT replay application frames, so this + // event is the only hook for replaying them. + const addr = `tcp://127.0.0.1:${nextPort()}` + + const first = new RouterSocket() + await first.bind(addr) + + const dealer = new DealerSocket({routingId: 'processor-1', probeRouter: true}) + const reconnected: Promise = new Promise((resolve) => + dealer.watchReconnect().once('reconnect', () => resolve())) + dealer.connect(addr) + open.push(dealer) + + await nextFrames(first) // established + await first.close() + + // A fresh ROUTER takes over the same endpoint. + const second = new RouterSocket() + await second.bind(addr) + open.push(second) + + await reconnected + + // And the replay actually lands on the NEW router. + const replayed = nextFrames(second) + await dealer.send([Buffer.from('A'), Buffer.from('provider-a')]) + expect((await replayed).map(f => f.toString())) + .toEqual(['processor-1', 'A', 'provider-a']) + }) + + it('stops emitting reconnect after the socket is closed', async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'processor-1', probeRouter: true}) + let reconnects = 0 + dealer.watchReconnect().on('reconnect', () => reconnects++) + dealer.connect(addr) + open.push(dealer) + + await nextFrames(router) + await dealer.close() + await router.close() + open.splice(open.indexOf(router), 1) + open.splice(open.indexOf(dealer), 1) + + // Give the OS time to release the port from TIME_WAIT before rebinding. + await new Promise(r => setTimeout(r, 200)) + + // Rebinding must not resurrect events on a closed socket. + const revived = new RouterSocket() + await revived.bind(addr) + open.push(revived) + await new Promise(r => setTimeout(r, 300)) + + expect(reconnects).toBe(0) + }) + + it('routes a raw send from the ROUTER back to the addressed DEALER', async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'dev-1'}) + dealer.connect(addr) + open.push(dealer) + + // Let the ROUTER learn the peer identity via a first message. + const first = nextFrames(router) + await dealer.send([Buffer.from('ping')]) + const identity = (await first)[0] + + const reply = nextFrames(dealer) + await router.send([identity, Buffer.from('pong')]) + + const got = await reply + // The DEALER does not carry an identity frame; it sees only the body. + expect(got[0].toString()).toBe('pong') + }) + + it('serialises concurrent sends without loss or error', async () => { + // Reproduces "Socket is busy writing" from production: logcat fires + // transport.send() for every entry without awaiting. zeromq v6 rejects + // the 2nd concurrent call at the native layer. + // + // We wrap the native socket in a Proxy that adds a 2ms delay to each + // send, making concurrent in-flight calls detectable. Without the + // tail-chain fix: violations > 0. With it: violations === 0. + const addr = `tcp://127.0.0.1:${nextPort()}` + + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'dev-busy'}) + dealer.connect(addr) + open.push(dealer) + + // Replace the internal native socket with a Proxy that slows sends. + let inflight = 0 + let violations = 0 + const realSocket = (dealer as any).socket + ;(dealer as any).socket = new Proxy(realSocket, { + get(target, prop) { + if (prop !== 'send') return (target as any)[prop] + return async (frames: any) => { + if (inflight > 0) violations++ + inflight++ + await new Promise(r => setTimeout(r, 2)) + inflight-- + return target.send(frames) + } + } + }) + + await Promise.all( + Array.from({length: 10}, (_, i) => dealer.send([Buffer.from(`msg-${i}`)])) + ) + + expect(violations).toBe(0) // RED without tail-chain: violations > 0 + }) + + it('continues sending after a transient send error', async () => { + // If the native socket rejects once (e.g. EHOSTUNREACH during reconnect) + // the chain must recover: subsequent sends must still be attempted. + // Without .catch() on sendTail, a single rejection poisons the whole + // chain and all future sends are silently dropped forever. + const addr = `tcp://127.0.0.1:${nextPort()}` + + const router = new RouterSocket() + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'dev-recover'}) + dealer.connect(addr) + open.push(dealer) + + let callCount = 0 + const realSocket = (dealer as any).socket + ;(dealer as any).socket = new Proxy(realSocket, { + get(target, prop) { + if (prop !== 'send') return (target as any)[prop] + return async (frames: any) => { + callCount++ + // Simulate one transient error on the 2nd send. + if (callCount === 2) throw new Error('EHOSTUNREACH') + return target.send(frames) + } + } + }) + + const results = await Promise.allSettled([ + dealer.send([Buffer.from('msg-1')]), + dealer.send([Buffer.from('msg-2')]), // this one errors + dealer.send([Buffer.from('msg-3')]), // must still be attempted + ]) + + // msg-3 must reach the router — chain must recover after error. + await new Promise(r => setTimeout(r, 50)) + expect(callCount).toBe(3) // RED without .catch(): callCount === 2 + }) +}) diff --git a/test/unit/wire/app-transport.integration.test.ts b/test/unit/wire/app-transport.integration.test.ts new file mode 100644 index 0000000000..a7d3b55686 --- /dev/null +++ b/test/unit/wire/app-transport.integration.test.ts @@ -0,0 +1,158 @@ +import {describe, it, expect, afterEach} from 'vitest' +import {RouterSocket, DealerSocket} from '../../../lib/util/zmqsocket.ts' +import {AppTransport} from '../../../lib/wire/app-transport.ts' +import {ProxyRouting} from '../../../lib/units/proxy/routing.ts' +import {KIND, encodeBroadcast, deviceKey} from '../../../lib/wire/frame.ts' +import {Envelope} from '../../../lib/wire/wire.ts' +import {DeviceHeartbeatMessage} from '../../../lib/wire/wire.ts' +import wireutil from '../../../lib/wire/util.ts' + +// Integration-style test over real in-process TCP sockets: an app-side +// AppTransport (DEALER) talking to a real proxy ROUTER driven by the pure +// ProxyRouting. No ZMQ mocking — this exercises the S-registration and B/D +// paths end to end, the way api/websocket/log will use them. + +const nextPort = (() => { + let p = 16100 + return () => p++ +})() + +// A ROUTER receiving from a probeRouter=true DEALER sees an initial empty probe +// ([identity]) before the first real message. Skip probe/empty frames. +const nextRealFrames = (sock: {on: (e: string, fn: (f: Buffer[]) => void) => void; removeListener: (e: string, fn: (f: Buffer[]) => void) => void}): Promise => + new Promise((resolve) => { + const onFrames = (frames: Buffer[]) => { + if (frames.length <= 1 || (frames.length === 2 && frames[1].length === 0)) { + return + } + sock.removeListener('frames', onFrames) + resolve(frames) + } + sock.on('frames', onFrames) + }) + +describe('AppTransport <-> proxy ROUTER', () => { + const open: Array<{close: () => any}> = [] + afterEach(async () => { + await Promise.all(open.splice(0).map(s => Promise.resolve(s.close()).catch(() => {}))) + }) + + // A minimal real proxy: a RouterSocket wired to the pure ProxyRouting, the + // same glue lib/units/proxy/index.ts uses (without the lifecycle globals). + const setup = async () => { + const addr = `tcp://127.0.0.1:${nextPort()}` + const routing = new ProxyRouting() + const router = new RouterSocket() + router.on('frames', async (frames: Buffer[]) => { + for (const {target, frames: out} of routing.route(frames)) { + await router.send([target, ...out]).catch(() => {}) + } + }) + await router.bind(addr) + open.push(router) + + const dealer = new DealerSocket({routingId: 'app-1', probeRouter: true}) + dealer.connect(addr) + const transport = new AppTransport(dealer) + open.push(transport) + return {addr, router, routing, transport} + } + + it('registers as a broadcast receiver (S) and then receives a broadcast (B)', async () => { + const {router, routing, transport} = await setup() + + // The app registers itself; the proxy learns the receiver from the [S]. + const learn = nextRealFrames(router) + transport.registerBroadcast() + await learn // the proxy has now processed the [S] frame + + const gotBroadcast = new Promise((resolve) => { + transport.once('broadcast', (body: Buffer) => resolve(body)) + }) + + // A broadcast source (processor/groups-engine) publishes a B to the + // proxy; the proxy fans it out to every registered receiver. + const env = Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial: 'serial-1'})) + for (const {target, frames} of routing.route([ + Buffer.from('source-1'), + ...encodeBroadcast(env), + ])) { + await router.send([target, ...frames]) + } + + const body = await gotBroadcast + const decoded = Envelope.fromBinary(body) + expect(decoded.message?.typeUrl).toContain('DeviceHeartbeatMessage') + }) + + it('sends a device command (D) that the proxy forwards to the owning processor', async () => { + const {addr, router, routing, transport} = await setup() + + // Stand up a fake processor DEALER and announce it owns provider-a, so + // the proxy has a route for the command. + const processor = new DealerSocket({routingId: 'proc-1', probeRouter: true}) + processor.connect(addr) + open.push(processor) + + const announced = nextRealFrames(router) + await processor.send([Buffer.from(KIND.ANNOUNCE), Buffer.from('provider-a')]) + await announced // proxy learned provider-a -> proc-1 + + // The processor should receive the forwarded command with the app's + // routingId pushed onto the reply-path. + const gotCommand = nextRealFrames(processor) + + const correlationId = 'txn_app' + const commandEnv = Buffer.from(wireutil.tr(correlationId, DeviceHeartbeatMessage, {serial: 'serial-1'})) + transport.sendCommand('provider-a', 'serial-1', commandEnv) + + const frames = await gotCommand + // [D, providerName, serial, ...replyPath, envelope] + expect(frames[0].toString()).toBe(KIND.DEVICE) + expect(frames[1].toString()).toBe('provider-a') + expect(frames[2].toString()).toBe('serial-1') + // the proxy pushed the app DEALER routingId onto the reply-path + expect(frames[3].toString()).toBe('app-1') + const decoded = Envelope.fromBinary(frames[frames.length - 1]) + expect(decoded.channel).toBe(correlationId) + }) + + it('delivers a reply (R) back to the app as a message carrying the correlationId', async () => { + const {addr, router, routing, transport} = await setup() + + // Register + announce a processor so the proxy has both a receiver and a + // route (mirrors the full topology). + const processor = new DealerSocket({routingId: 'proc-1', probeRouter: true}) + processor.connect(addr) + open.push(processor) + const announced = nextRealFrames(router) + await processor.send([Buffer.from(KIND.ANNOUNCE), Buffer.from('provider-a')]) + await announced + + // Send a command so the proxy records the app on the reply-path. + const gotCommand = nextRealFrames(processor) + const correlationId = 'txn_reply' + transport.sendCommand('provider-a', 'serial-1', + Buffer.from(wireutil.tr(correlationId, DeviceHeartbeatMessage, {serial: 'serial-1'}))) + const command = await gotCommand + // reply-path is everything between serial (idx 2) and the body. + const replyPath = command.slice(3, command.length - 1) + + const gotReply = new Promise<{channel: string; body: Buffer}>((resolve) => { + transport.once('message', (channel: string, body: Buffer) => resolve({channel, body})) + }) + + // The processor replies up: [R, ...replyPath, envelope]. The proxy pops + // the app off the reply-path and delivers [R, envelope] to the app. + const replyEnv = Buffer.from(Envelope.toBinary({ + ...Envelope.fromBinary(Buffer.from(wireutil.reply('serial-1').okay('success'))), + channel: correlationId, + })) + await processor.send([Buffer.from(KIND.REPLY), ...replyPath, replyEnv]) + + const reply = await gotReply + expect(reply.channel).toBe(correlationId) + const decoded = Envelope.fromBinary(reply.body) + expect(decoded.message?.typeUrl).toContain('TransactionDoneMessage') + }) +}) diff --git a/test/unit/wire/app-transport.test.ts b/test/unit/wire/app-transport.test.ts new file mode 100644 index 0000000000..e12c96a28a --- /dev/null +++ b/test/unit/wire/app-transport.test.ts @@ -0,0 +1,97 @@ +import {describe, it, expect} from 'vitest' +import {AppWire} from '../../../lib/wire/app-transport.ts' +import {KIND, deviceKey, BROADCAST_ALL} from '../../../lib/wire/frame.ts' +import {Envelope} from '../../../lib/wire/wire.ts' +import {DeviceHeartbeatMessage} from '../../../lib/wire/wire.ts' +import wireutil from '../../../lib/wire/util.ts' + +// Pure app-side wire logic (no ZMQ). AppWire turns an app unit's intent — +// "send this command to device (providerName, serial)" — into the ROUTER/DEALER +// device frame, and classifies inbound frames the proxy delivers to an app-side +// DEALER: replies (R) and broadcasts (B). The proxy strips the DEALER identity +// and, before delivering, has already popped this receiver off any reply-path, +// so an app-side DEALER sees replies as [R, envelope] and broadcasts as +// [B, selector, envelope]. + +const str = (frames: Buffer[]) => frames.map(b => b.toString()) + +const heartbeatEnvelope = (serial: string, channel?: string): Buffer => + Buffer.from(wireutil.pack(DeviceHeartbeatMessage, {serial}, channel)) + +describe('AppWire: outbound device command', () => { + it('encodes a device command as [D, providerName, serial, envelope]', () => { + const wire = new AppWire() + const env = heartbeatEnvelope('serial-1') + + const out = wire.encodeCommand('provider-a', 'serial-1', env) + + expect(str(out).slice(0, 3)).toEqual([KIND.DEVICE, 'provider-a', 'serial-1']) + expect(out[out.length - 1]).toEqual(env) + }) +}) + +describe('AppWire: broadcast registration', () => { + it('encodes a single [S] frame to register as a broadcast receiver', () => { + const wire = new AppWire() + const out = wire.encodeSubscribe() + expect(str(out)).toEqual([KIND.SUBSCRIBE_BROADCAST]) + }) +}) + +describe('AppWire: outbound broadcast publish', () => { + it('encodes a broadcast as [B, BROADCAST_ALL, envelope] by default', () => { + const wire = new AppWire() + const env = heartbeatEnvelope('serial-1') + + const out = wire.encodeBroadcast(env) + + expect(str(out).slice(0, 2)).toEqual([KIND.BROADCAST, BROADCAST_ALL]) + expect(out[out.length - 1]).toEqual(env) + }) + + it('carries an explicit selector when given', () => { + const wire = new AppWire() + const env = heartbeatEnvelope('serial-1') + + const out = wire.encodeBroadcast(env, 'group-42') + + expect(str(out)).toEqual([KIND.BROADCAST, 'group-42', env.toString()]) + }) +}) + +describe('AppWire: inbound classification (DEALER, identity already stripped)', () => { + it('classifies a reply [R, envelope] as a reply carrying the correlationId', () => { + const wire = new AppWire() + const correlationId = 'txn_abc' + const env = heartbeatEnvelope('serial-1', correlationId) + + const result = wire.classifyInbound([Buffer.from(KIND.REPLY), env]) + + expect(result).not.toBeNull() + expect(result!.kind).toBe(KIND.REPLY) + expect(result!.channel).toBe(correlationId) + expect(result!.body).toEqual(env) + }) + + it('classifies a broadcast [B, selector, envelope] as a broadcast body', () => { + const wire = new AppWire() + const env = heartbeatEnvelope('serial-1') + + const result = wire.classifyInbound([Buffer.from(KIND.BROADCAST), Buffer.from(''), env]) + + expect(result).not.toBeNull() + expect(result!.kind).toBe(KIND.BROADCAST) + expect(result!.body).toEqual(env) + }) + + it('drops an empty probe/keepalive frame (returns null)', () => { + const wire = new AppWire() + expect(wire.classifyInbound([])).toBeNull() + expect(wire.classifyInbound([Buffer.from('')])).toBeNull() + }) + + it('drops an unexpected kind (returns null)', () => { + const wire = new AppWire() + expect(wire.classifyInbound([Buffer.from('Z'), Buffer.from('x')])).toBeNull() + }) +}) diff --git a/test/unit/wire/frame.test.ts b/test/unit/wire/frame.test.ts new file mode 100644 index 0000000000..0241434b9f --- /dev/null +++ b/test/unit/wire/frame.test.ts @@ -0,0 +1,282 @@ +import {describe, it, expect} from 'vitest' +import { + deviceKey, + KIND, + CORRELATION_PREFIX, + BROADCAST_ALL, + encodeDeviceFrame, + encodeEvent, + encodeReply, + encodeAnnounce, + encodeHello, + encodeInit, + encodeBroadcast, + pushReplyPath, + popReplyPath, + classifyFrames, +} from '../../../lib/wire/frame.ts' + +describe('deviceKey', () => { + it('composes a cluster-unique key from providerName and serial', () => { + // Two emulators with the same serial behind different providers must + // produce distinct keys (the non-unique-serial problem). + expect(deviceKey('provider-a', 'emulator-5554')) + .not.toBe(deviceKey('provider-b', 'emulator-5554')) + }) +}) + +describe('encodeDeviceFrame', () => { + it('produces [D, providerName, serial, envelope] with an empty reply-path', () => { + const envelope = Buffer.from('body') + const frames = encodeDeviceFrame('provider-a', 'emulator-5554', envelope) + + expect(frames).toHaveLength(4) + expect(frames[0].toString()).toBe(KIND.DEVICE) + expect(frames[1].toString()).toBe('provider-a') + expect(frames[2].toString()).toBe('emulator-5554') + expect(frames[3]).toBe(envelope) + }) +}) + +describe('encodeEvent', () => { + it('produces [E, envelope] — a device-originated event for the processor', () => { + const envelope = Buffer.from('body') + const frames = encodeEvent(envelope) + + expect(frames).toHaveLength(2) + expect(frames[0].toString()).toBe(KIND.EVENT) + expect(frames[1]).toBe(envelope) + }) +}) + +describe('encodeReply', () => { + it('produces [R, ...replyPath, envelope]', () => { + const envelope = Buffer.from('body') + const path = [Buffer.from('api'), Buffer.from('proxy')] + const frames = encodeReply(path, envelope) + + expect(frames.map(f => f.toString())) + .toEqual([KIND.REPLY, 'api', 'proxy', 'body']) + }) + + it('produces [R, envelope] for an empty reply-path', () => { + const envelope = Buffer.from('body') + const frames = encodeReply([], envelope) + + expect(frames.map(f => f.toString())).toEqual([KIND.REPLY, 'body']) + }) +}) + +describe('encodeAnnounce', () => { + it('produces [A, providerName]', () => { + const frames = encodeAnnounce('provider-a') + + expect(frames.map(f => f.toString())).toEqual([KIND.ANNOUNCE, 'provider-a']) + }) +}) + +describe('encodeHello', () => { + it('produces a bodyless [H]', () => { + expect(encodeHello().map(f => f.toString())).toEqual([KIND.HELLO]) + }) + + it('carries no payload at all — it is pure identification', () => { + expect(encodeHello()).toHaveLength(1) + }) + + it('is classified as HELLO once a ROUTER prepends the sender identity', () => { + const inbound = [Buffer.from('processor-1'), ...encodeHello()] + const {sender, kind, message} = classifyFrames(inbound, {routerPrepended: true}) + + expect(sender!.toString()).toBe('processor-1') + expect(kind).toBe(KIND.HELLO) + expect(message.map(f => f.toString())).toEqual([KIND.HELLO]) + }) +}) + +describe('encodeInit', () => { + it('produces [I, startedAt] with the timestamp as a decimal string', () => { + expect(encodeInit(1700000000123).map(f => f.toString())) + .toEqual([KIND.INIT, '1700000000123']) + }) + + it('round-trips a Date.now()-shaped timestamp without precision loss', () => { + // epoch millis exceed 2^32, so a naive 4-byte encoding would truncate. + const now = 1764250000999 + const [, ts] = encodeInit(now) + + expect(Number(ts.toString())).toBe(now) + }) + + it('never emits exponential notation for realistic timestamps', () => { + const [, ts] = encodeInit(Date.now()) + + expect(ts.toString()).toMatch(/^\d+$/) + }) + + it('is classified as INIT off a DEALER, with the timestamp as the tail frame', () => { + // The processor reads this off its DEALER, so no identity is prepended. + const {sender, kind, message} = classifyFrames(encodeInit(42), {routerPrepended: false}) + + expect(sender).toBeNull() + expect(kind).toBe(KIND.INIT) + expect(message.slice(1).map(f => f.toString())).toEqual(['42']) + }) +}) + +describe('control-plane kinds are mutually distinct', () => { + it('gives every kind its own single-character tag', () => { + const kinds = Object.values(KIND) + + expect(new Set(kinds).size).toBe(kinds.length) + expect(kinds.every(k => k.length === 1)).toBe(true) + }) + + it('does not collide HELLO/INIT with the pre-existing kinds', () => { + // A regression guard: reusing a letter would silently reroute traffic. + expect(KIND.HELLO).not.toBe(KIND.ANNOUNCE) + expect(KIND.HELLO).not.toBe(KIND.SUBSCRIBE_BROADCAST) + expect(KIND.INIT).not.toBe(KIND.DEVICE) + expect(KIND.INIT).not.toBe(KIND.REPLY) + expect(KIND.INIT).not.toBe(KIND.EVENT) + expect(KIND.INIT).not.toBe(KIND.BROADCAST) + }) +}) + +describe('encodeBroadcast', () => { + it('produces [B, BROADCAST_ALL, envelope] by default', () => { + const envelope = Buffer.from('body') + const frames = encodeBroadcast(envelope) + + expect(frames[0].toString()).toBe(KIND.BROADCAST) + expect(frames[1].toString()).toBe(BROADCAST_ALL) + expect(frames[2]).toBe(envelope) + }) +}) + +describe('protocol constants', () => { + it('CORRELATION_PREFIX is the txn_ marker shared with the transaction manager', () => { + expect(CORRELATION_PREFIX).toBe('txn_') + }) + + it('BROADCAST_ALL is the empty selector', () => { + expect(BROADCAST_ALL).toBe('') + }) +}) + +describe('classifyFrames', () => { + it('reads kind and message from a DEALER frame (no prepended identity)', () => { + // From a DEALER the identity is already stripped: [kind, ...message]. + const frames = [Buffer.from(KIND.EVENT), Buffer.from('body')] + + const {sender, kind, message} = classifyFrames(frames, {routerPrepended: false}) + + expect(sender).toBeNull() + expect(kind).toBe(KIND.EVENT) + expect(message.map(f => f.toString())).toEqual([KIND.EVENT, 'body']) + }) + + it('reads sender, kind and message from a ROUTER frame (prepended identity)', () => { + // A ROUTER prepends the sender identity: [senderId, kind, ...message]. + const frames = [Buffer.from('device-1'), Buffer.from(KIND.REPLY), Buffer.from('body')] + + const {sender, kind, message} = classifyFrames(frames, {routerPrepended: true}) + + expect(sender!.toString()).toBe('device-1') + expect(kind).toBe(KIND.REPLY) + // message excludes the identity but keeps the kind + rest + expect(message.map(f => f.toString())).toEqual([KIND.REPLY, 'body']) + }) +}) + +describe('pushReplyPath', () => { + it('inserts the routingId onto the tail of the path, just before the body', () => { + const envelope = Buffer.from('body') + const frames = encodeDeviceFrame('provider-a', 'serial-1', envelope) + + const forwarded = pushReplyPath(frames, Buffer.from('api-id')) + + // header (D, provider, serial) + one path id + body + expect(forwarded.map(f => f.toString())) + .toEqual([KIND.DEVICE, 'provider-a', 'serial-1', 'api-id', 'body']) + }) + + it('stacks multiple ids in push order (LIFO stack, newest last)', () => { + const envelope = Buffer.from('body') + let frames = encodeDeviceFrame('provider-a', 'serial-1', envelope) + + frames = pushReplyPath(frames, Buffer.from('api-id')) + frames = pushReplyPath(frames, Buffer.from('proxy-id')) + + expect(frames.map(f => f.toString())) + .toEqual([KIND.DEVICE, 'provider-a', 'serial-1', 'api-id', 'proxy-id', 'body']) + }) +}) + +describe('popReplyPath', () => { + it('pops the tail routingId (LIFO) and returns the remaining reply frames', () => { + // Reply travelling down: ["R", api-id, proxy-id, body] + // newest pushed id (proxy-id) comes off first. + const reply: Buffer[] = [ + Buffer.from(KIND.REPLY), + Buffer.from('api-id'), + Buffer.from('proxy-id'), + Buffer.from('body'), + ] + + const popped = popReplyPath(reply) + + expect(popped.routingId!.toString()).toBe('proxy-id') + expect(popped.frames.map(f => f.toString())) + .toEqual([KIND.REPLY, 'api-id', 'body']) + expect(popped.final).toBe(false) + }) + + it('signals the final recipient when the reply-path is empty', () => { + const reply: Buffer[] = [ + Buffer.from(KIND.REPLY), + Buffer.from('body'), + ] + + const popped = popReplyPath(reply) + + expect(popped.routingId).toBeNull() + expect(popped.final).toBe(true) + expect(popped.body.toString()).toBe('body') + }) +}) + +describe('reply-path round trip (tracer bullet)', () => { + it('routes a reply back through the exact chain it came from', () => { + // Down: api -> proxy -> processor -> device. + // Each hop pushes the routingId of the peer it received from. + const body = Buffer.from('command') + let down = encodeDeviceFrame('provider-a', 'serial-1', body) + down = pushReplyPath(down, Buffer.from('api')) + down = pushReplyPath(down, Buffer.from('proxy')) + + expect(down.map(f => f.toString())) + .toEqual([KIND.DEVICE, 'provider-a', 'serial-1', 'api', 'proxy', 'command']) + + // Device replies. Up-to-down: processor pops "proxy" (send to proxy), + // proxy pops "api" (send to api), api sees empty path -> final recipient. + let reply: Buffer[] = [ + Buffer.from(KIND.REPLY), + Buffer.from('api'), + Buffer.from('proxy'), + Buffer.from('reply-body'), + ] + + const atProcessor = popReplyPath(reply) + expect(atProcessor.routingId!.toString()).toBe('proxy') + expect(atProcessor.final).toBe(false) + + const atProxy = popReplyPath(atProcessor.frames) + expect(atProxy.routingId!.toString()).toBe('api') + expect(atProxy.final).toBe(false) + + const atApi = popReplyPath(atProxy.frames) + expect(atApi.final).toBe(true) + expect(atApi.body.toString()).toBe('reply-body') + }) +}) diff --git a/test/unit/wire/router.test.ts b/test/unit/wire/router.test.ts new file mode 100644 index 0000000000..2996c12f35 --- /dev/null +++ b/test/unit/wire/router.test.ts @@ -0,0 +1,49 @@ +import {describe, it, expect, vi} from 'vitest' +import {WireRouter} from '../../../lib/wire/router.ts' +import {Envelope, DeviceHeartbeatMessage} from '../../../lib/wire/wire.ts' +import {Any} from '../../../lib/wire/google/protobuf/any.ts' + +// Characterization test for decision 11: WireRouter is kept unchanged; only its +// event source moves from a `sub` socket to a `dealer` socket. The dealer, via +// SocketWrapper, emits 'message' as (frame0AsString, ...restFrames), so the +// handler is called as (channel, data) exactly like before. These tests pin +// that behaviour so the source swap is safe. + +const packHeartbeat = (serial: string, channel?: string) => + Buffer.from(Envelope.toBinary({ + message: Any.pack({serial}, DeviceHeartbeatMessage), + channel, + })) + +describe('WireRouter.handler as a dealer message source', () => { + it('dispatches by protobuf type from a (channel, data) call', () => { + const seen = vi.fn() + const handler = new WireRouter() + .on(DeviceHeartbeatMessage, seen) + .handler() + + // Simulate dealer.on('message', handler): channel = frame0, data = frame1 + handler('some-channel', packHeartbeat('serial-1')) + + expect(seen).toHaveBeenCalledTimes(1) + const [channel, message] = seen.mock.calls[0] + expect(channel).toBe('some-channel') + expect(message.serial).toBe('serial-1') + }) + + it('prefers the Envelope.channel over the transport channel when present', () => { + const seen = vi.fn() + const handler = new WireRouter() + .on(DeviceHeartbeatMessage, seen) + .handler() + + handler('transport-channel', packHeartbeat('serial-1', 'envelope-channel')) + + expect(seen.mock.calls[0][0]).toBe('envelope-channel') + }) + + it('silently ignores unregistered message types', () => { + const handler = new WireRouter().handler() + expect(() => handler('c', packHeartbeat('serial-1'))).not.toThrow() + }) +}) diff --git a/test/unit/wire/seqqueue.test.ts b/test/unit/wire/seqqueue.test.ts new file mode 100644 index 0000000000..a55aab60c9 --- /dev/null +++ b/test/unit/wire/seqqueue.test.ts @@ -0,0 +1,103 @@ +import {describe, it, expect} from 'vitest' +import SeqQueue from '../../../lib/wire/seqqueue.ts' + +// SeqQueue reorders out-of-sequence handlers back into monotonic `seq` order. +// It is the touch-input reorder buffer: the frontend mints a cyclic `seq` +// (0..cycle-1, cycle=100 => SeqQueue(100, 4)) and messages may arrive out of +// order over the wire. The queue delivers them in order, tolerating up to +// `maxWaiting` missing slots before skipping a gap so it never stalls forever. + +describe('SeqQueue', () => { + it('is locked before start(): pushed handlers do not run', () => { + const queue = new SeqQueue(10, 4) + let ran = false + queue.push(0, () => { + ran = true + }) + expect(ran).toBe(false) + }) + + it('delivers in-order handlers after start()', () => { + const queue = new SeqQueue(10, 4) + const order: number[] = [] + // start(seq) primes lo = seq + 1, so the first expected seq is 1. + queue.start(0) + queue.push(1, () => order.push(1)) + queue.push(2, () => order.push(2)) + queue.push(3, () => order.push(3)) + expect(order).toEqual([1, 2, 3]) + }) + + it('buffers an out-of-order handler and flushes once the gap fills', () => { + const queue = new SeqQueue(10, 4) + const order: number[] = [] + queue.start(0) + // seq 2 arrives before seq 1 -> buffered, nothing delivered yet. + queue.push(2, () => order.push(2)) + expect(order).toEqual([]) + // seq 1 arrives -> both 1 and 2 flush in order. + queue.push(1, () => order.push(1)) + expect(order).toEqual([1, 2]) + }) + + it('skips a gap once waiting reaches maxWaiting (never stalls forever)', () => { + const queue = new SeqQueue(10, 2) + const order: number[] = [] + queue.start(0) + // Expected next is 1. seq 2 arrives (waiting = 1 < maxWaiting) -> buffered. + queue.push(2, () => order.push(2)) + expect(order).toEqual([]) + // seq 3 arrives (waiting = 2 = maxWaiting): the queue gives up on the + // missing seq 1, skips that slot, and delivers seq 2. It stops after + // consuming 2 because `waiting` is exhausted, so seq 3 stays buffered + // until the next push advances the cursor to it. + queue.push(3, () => order.push(3)) + expect(order).toEqual([2]) + // Each subsequent push advances the cursor by one, draining one buffered + // handler: seq 4 arrives -> the buffered seq 3 is delivered. + queue.push(4, () => order.push(4)) + expect(order).toEqual([2, 3]) + // seq 5 arrives -> the buffered seq 4 is delivered. + queue.push(5, () => order.push(5)) + expect(order).toEqual([2, 3, 4]) + }) + + it('wraps around at size boundary (cyclic seq)', () => { + // Mirrors the production SeqQueue(100, 4) fed by the frontend cycle=100. + const size = 4 + const queue = new SeqQueue(size, 4) + const order: number[] = [] + // start near the end so the next expected seq wraps back to 0. + queue.start(size - 2) // lo = size - 1 = 3 + queue.push(size - 1, () => order.push(size - 1)) // seq 3 + queue.push(0, () => order.push(100)) // wraps to slot 0 + queue.push(1, () => order.push(101)) + expect(order).toEqual([size - 1, 100, 101]) + }) + + it('ignores a push with seq >= size', () => { + const queue = new SeqQueue(4, 4) + const order: number[] = [] + queue.start(-1) // lo = 0 + queue.push(4, () => order.push(4)) // out of range -> dropped + queue.push(0, () => order.push(0)) + expect(order).toEqual([0]) + }) + + it('stops consuming when a queued handler calls stop()', () => { + const queue = new SeqQueue(10, 4) + const order: number[] = [] + queue.start(0) + // seq 1 handler stops the queue (like GestureStop in the touch plugin). + queue.push(1, () => { + order.push(1) + queue.stop() + }) + // seq 2 is buffered but must not run while stopped. + queue.push(2, () => order.push(2)) + expect(order).toEqual([1]) + // restarting resumes delivery of the buffered handler. + queue.start(1) // lo = 2 + expect(order).toEqual([1, 2]) + }) +}) diff --git a/test/unit/wire/transmanager.test.ts b/test/unit/wire/transmanager.test.ts new file mode 100644 index 0000000000..74c58e3376 --- /dev/null +++ b/test/unit/wire/transmanager.test.ts @@ -0,0 +1,226 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest' +import {TransactionManager} from '../../../lib/wire/transmanager.ts' +import {GetPresentDevices, TransactionDoneMessage, TransactionProgressMessage} from '../../../lib/wire/wire.ts' +import {Envelope} from '../../../lib/wire/wire.ts' +import {Any} from '../../../lib/wire/google/protobuf/any.ts' + +// A minimal fake AppTransport: records device commands sent and lets tests emit +// replies the way the real AppTransport does (a 'message' event carrying the +// reply's correlationId as the channel and the Envelope body). +class FakeTransport { + sent: Array<{providerName: string; serial: string; envelope: Buffer}> = [] + private handler: ((channel: string, data: Buffer) => void) | null = null + + sendCommand(providerName: string, serial: string, envelope: Uint8Array) { + this.sent.push({providerName, serial, envelope: Buffer.from(envelope)}) + } + + on(event: string, fn: (channel: string, data: Buffer) => void) { + if (event === 'message') { + this.handler = fn + } + return this + } + + // Simulate AppTransport emitting a reply: ('message', channel, body). + deliver(channel: string, data: Buffer) { + this.handler?.(channel, data) + } +} + +// Build a TransactionDoneMessage reply Envelope, carrying the correlationId in +// the Envelope.channel slot (how a reply is correlated to its request). +const buildReply = (correlationId: string, success: boolean, body?: object) => + Buffer.from(Envelope.toBinary({ + message: Any.pack({ + source: 'serial-1', + seq: 0, + success, + data: success ? 'success' : 'fail', + body: body ? JSON.stringify(body) : undefined, + }, TransactionDoneMessage), + channel: correlationId, + })) + +// A reply whose payload lives in the `data` field (how ConnectStart/Install +// carry the url / result string), with no JSON body. +const buildReplyWithData = (correlationId: string, success: boolean, data: string) => + Buffer.from(Envelope.toBinary({ + message: Any.pack({ + source: 'serial-1', + seq: 0, + success, + data, + }, TransactionDoneMessage), + channel: correlationId, + })) + +// A progress reply (how shell/install stream their output/progress before the +// final done). Carried on the same reply-path as the done, correlated by +// Envelope.channel. +const buildProgress = (correlationId: string, seq: number, data: string, progress = 0) => + Buffer.from(Envelope.toBinary({ + message: Any.pack({ + source: 'serial-1', + seq, + data, + progress, + }, TransactionProgressMessage), + channel: correlationId, + })) + +describe('TransactionManager', () => { + let transport: FakeTransport + let tm: TransactionManager + + beforeEach(() => { + vi.useFakeTimers() + transport = new FakeTransport() + tm = new TransactionManager(transport as any) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('sends one device command carrying a fresh correlation id', () => { + tm.runTransaction('provider-a', 'serial-1', GetPresentDevices, {}) + + expect(transport.sent).toHaveLength(1) + const {providerName, serial, envelope} = transport.sent[0] + expect(providerName).toBe('provider-a') + expect(serial).toBe('serial-1') + // The envelope must carry a correlationId in its channel slot so the + // reply can be matched back. + const decoded = Envelope.fromBinary(envelope) + expect(decoded.channel).toBeTruthy() + }) + + const correlationIdOfLastSend = () => { + const {envelope} = transport.sent[transport.sent.length - 1] + return Envelope.fromBinary(envelope).channel! + } + + it('resolves with {success, data, body} when a matching reply arrives', async () => { + const promise = tm.runTransaction('p', 's', GetPresentDevices, {}) + const correlationId = correlationIdOfLastSend() + + transport.deliver(correlationId, buildReply(correlationId, true, {devices: ['s1', 's2']})) + + await expect(promise).resolves.toEqual({ + success: true, + data: 'success', + body: {devices: ['s1', 's2']}, + }) + }) + + it('exposes the reply `data` field (e.g. a connect url) to the resolved value', async () => { + const promise = tm.runTransaction('p', 's', GetPresentDevices, {}) + const correlationId = correlationIdOfLastSend() + + // ConnectStart/Install replies carry their payload (url, result string) + // in the `data` field, not `body`. + transport.deliver(correlationId, buildReplyWithData(correlationId, true, 'ws://device:1234')) + + await expect(promise).resolves.toEqual({ + success: true, + data: 'ws://device:1234', + body: {}, + }) + }) + + it('rejects with {success, data, body} when the reply signals failure', async () => { + const promise = tm.runTransaction('p', 's', GetPresentDevices, {}) + const correlationId = correlationIdOfLastSend() + + transport.deliver(correlationId, buildReply(correlationId, false, {reason: 'nope'})) + + await expect(promise).rejects.toEqual({ + success: false, + data: 'fail', + body: {reason: 'nope'}, + }) + }) + + it('rejects with a timeout error and cleans up the pending entry', async () => { + const promise = tm.runTransaction('p', 's', GetPresentDevices, {}, {timeout: 1000}) + const correlationId = correlationIdOfLastSend() + + const assertion = expect(promise).rejects.toThrow('Timeout when running transaction') + await vi.advanceTimersByTimeAsync(1000) + await assertion + + // A late reply after timeout must be a no-op (entry already removed). + expect(() => transport.deliver(correlationId, buildReply(correlationId, true, {devices: []}))) + .not.toThrow() + }) + + it('streams progress replies to onProgress before resolving on done', async () => { + const progress: Array<{data?: string; progress?: number; seq: number}> = [] + const promise = tm.runTransaction('p', 's', GetPresentDevices, {}, { + onProgress: (data, prog, seq) => progress.push({data, progress: prog, seq}), + }) + const correlationId = correlationIdOfLastSend() + + // shell/install stream their output as progress messages... + transport.deliver(correlationId, buildProgress(correlationId, 0, 'line-0', 10)) + transport.deliver(correlationId, buildProgress(correlationId, 1, 'line-1', 50)) + // ...then finalize with a done. + transport.deliver(correlationId, buildReply(correlationId, true, {})) + + await expect(promise).resolves.toEqual({success: true, data: 'success', body: {}}) + expect(progress).toEqual([ + {data: 'line-0', progress: 10, seq: 0}, + {data: 'line-1', progress: 50, seq: 1}, + ]) + }) + + it('does not require onProgress: progress replies are harmless without it', async () => { + const promise = tm.runTransaction('p', 's', GetPresentDevices, {}) + const correlationId = correlationIdOfLastSend() + + expect(() => transport.deliver(correlationId, buildProgress(correlationId, 0, 'ignored'))) + .not.toThrow() + transport.deliver(correlationId, buildReply(correlationId, true, {})) + + await expect(promise).resolves.toEqual({success: true, data: 'success', body: {}}) + }) + + it('routes progress only to its own transaction (no cross-talk)', async () => { + const p1: string[] = [] + const p2: string[] = [] + const promise1 = tm.runTransaction('p', 's', GetPresentDevices, {}, { + onProgress: (data) => p1.push(data ?? ''), + }) + const id1 = correlationIdOfLastSend() + const promise2 = tm.runTransaction('p', 's', GetPresentDevices, {}, { + onProgress: (data) => p2.push(data ?? ''), + }) + const id2 = correlationIdOfLastSend() + + transport.deliver(id1, buildProgress(id1, 0, 'for-1')) + transport.deliver(id2, buildProgress(id2, 0, 'for-2')) + transport.deliver(id1, buildReply(id1, true, {})) + transport.deliver(id2, buildReply(id2, true, {})) + + await Promise.all([promise1, promise2]) + expect(p1).toEqual(['for-1']) + expect(p2).toEqual(['for-2']) + }) + + it('handles two concurrent transactions on one transport independently', async () => { + const p1 = tm.runTransaction('p', 's', GetPresentDevices, {}) + const id1 = correlationIdOfLastSend() + const p2 = tm.runTransaction('p', 's', GetPresentDevices, {}) + const id2 = correlationIdOfLastSend() + + expect(id1).not.toBe(id2) + + // Reply to the second one first: they must not cross-resolve. + transport.deliver(id2, buildReply(id2, true, {devices: ['second']})) + transport.deliver(id1, buildReply(id1, true, {devices: ['first']})) + + await expect(p1).resolves.toEqual({success: true, data: 'success', body: {devices: ['first']}}) + await expect(p2).resolves.toEqual({success: true, data: 'success', body: {devices: ['second']}}) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index d8be88dfba..c07085db91 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,6 @@ "./lib/**/*.ts", "./bin/**/*.mjs" ], - "exclude": ["node_modules/**"], + "exclude": ["node_modules/**", "./test/**"], "verbose": true } diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 8dccd1c157..b82c74b826 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -7,5 +7,5 @@ "checkJs": false, "allowJs": true }, - "exclude": ["./lib/**/*.js"] + "exclude": ["./lib/**/*.js", "./test/**"] } diff --git a/ui/package-lock.json b/ui/package-lock.json index f1885b095d..cc12bad1bf 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "vk-devicehub-ui", - "version": "1.5.0", + "version": "1.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vk-devicehub-ui", - "version": "1.5.0", + "version": "1.5.2", "hasInstallScript": true, "dependencies": { "@lukemorales/query-key-factory": "^1.3.4", @@ -21,6 +21,7 @@ "classnames": "^2.5.1", "console-feed": "^3.8.0", "date-fns": "^4.1.0", + "exponential-backoff": "^3.1.3", "i18next": "^23.16.0", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", @@ -9236,6 +9237,12 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", diff --git a/ui/package.json b/ui/package.json index e406976d84..c7315212a5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "vk-devicehub-ui", "private": true, - "version": "1.5.2", + "version": "1.5.3", "type": "module", "scripts": { "dev": "vite", @@ -41,6 +41,7 @@ "classnames": "^2.5.1", "console-feed": "^3.8.0", "date-fns": "^4.1.0", + "exponential-backoff": "^3.1.3", "i18next": "^23.16.0", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", diff --git a/ui/src/api/socket.ts b/ui/src/api/socket.ts index 090d5f0a23..20664c1ba7 100644 --- a/ui/src/api/socket.ts +++ b/ui/src/api/socket.ts @@ -1,14 +1,65 @@ import { io } from 'socket.io-client' +import { backOff } from 'exponential-backoff' import { variablesConfig } from '@/config/variables.config' import { authStore } from '@/store/auth-store' export const socket = io(variablesConfig[import.meta.env.MODE].websocketUrl, { autoConnect: false, - reconnectionAttempts: 3, - reconnection: true, + reconnection: false, transports: ['websocket'], auth: (cb) => { cb({ token: authStore.jwt }) }, }) + +let reconnecting: Promise | null = null + +function connectSocket(): Promise { + return new Promise((resolve, reject) => { + if (socket.connected) { + resolve() + + return + } + + const onConnect = () => { + socket.off('connect_error', onError) + resolve() + } + + const onError = (err: Error) => { + socket.off('connect', onConnect) + reject(err) + } + + socket.once('connect', onConnect) + socket.once('connect_error', onError) + socket.connect() + }) +} + +export function connectWithBackoff(): Promise { + if (reconnecting) return reconnecting + + reconnecting = backOff(() => connectSocket(), { + numOfAttempts: 10, + startingDelay: 1000, + maxDelay: 30000, + jitter: 'full', + }) + .catch((err) => { + console.error('Socket.IO: all reconnection attempts exhausted', err) + }) + .finally(() => { + reconnecting = null + }) + + return reconnecting +} + +socket.on('disconnect', (reason) => { + if (reason === 'io client disconnect') return + + connectWithBackoff() +}) diff --git a/ui/src/components/app/app-router/require-auth.tsx b/ui/src/components/app/app-router/require-auth.tsx index b68e65942a..8638c5fdb0 100644 --- a/ui/src/components/app/app-router/require-auth.tsx +++ b/ui/src/components/app/app-router/require-auth.tsx @@ -4,10 +4,9 @@ import { useEffect } from 'react' import { ConditionalRender } from '@/components/lib/conditional-render' -import { socket } from '@/api/socket' +import { connectWithBackoff } from '@/api/socket' import { authStore } from '@/store/auth-store' -import { variablesConfig } from '@/config/variables.config' import { getAuthRoute } from '@/constants/route-paths' @@ -18,7 +17,7 @@ export const RequireAuth = observer(() => { const jwt = params.get('jwt') if (!jwt) { - window.location.assign(`${variablesConfig[import.meta.env.MODE].openStfApiHostUrl}${getAuthRoute()}`) + window.location.assign(`${getAuthRoute()}`) return } @@ -30,7 +29,7 @@ export const RequireAuth = observer(() => { useEffect(() => { if (authStore.isHydrated && authStore.isAuthed) { - socket.connect() + connectWithBackoff() } }, [authStore.isHydrated, authStore.isAuthed]) diff --git a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/advanced-tab.tsx b/ui/src/components/ui/device-control-panel/tabs/advanced-tab/advanced-tab.tsx index 4c83753461..29ddd57fb4 100644 --- a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/advanced-tab.tsx +++ b/ui/src/components/ui/device-control-panel/tabs/advanced-tab/advanced-tab.tsx @@ -1,13 +1,11 @@ import { Flex } from '@vkontakte/vkui' import { MaintenanceControl } from './maintenance-control' -import { PortForwardingControl } from './port-forwarding-control' import styles from './advanced-tab.module.css' export const AdvancedTab = () => ( - ) diff --git a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/index.ts b/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/index.ts deleted file mode 100644 index 3aaa011799..0000000000 --- a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PortForwardingControl } from './port-forwarding-control' diff --git a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forward-item.tsx b/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forward-item.tsx deleted file mode 100644 index 7589e351ff..0000000000 --- a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forward-item.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { observer } from 'mobx-react-lite' -import { useInjection } from 'inversify-react' -import { useTranslation } from 'react-i18next' -import { Checkbox, FormItem, FormLayoutGroup, Input } from '@vkontakte/vkui' - -import { CONTAINER_IDS } from '@/config/inversify/container-ids' - -import type { PortForwardEntry } from '@/services/port-forwarding-service/types' - -type PortForwardItemProps = { - className?: string -} - -export const PortForwardItem = observer( - ({ id, isEnabled, devicePort, targetHost, targetPort, className }: PortForwardItemProps & PortForwardEntry) => { - const { t } = useTranslation() - - const portForwardingService = useInjection(CONTAINER_IDS.portForwardingService) - - return ( - portForwardingService.removePortForward(id)} - > - portForwardingService.togglePortForward(id, event.target.checked)} - /> - - { - portForwardingService.setPortForwardValue(id, 'devicePort', event.target.valueAsNumber) - }} - /> - - - { - portForwardingService.setPortForwardValue(id, 'targetHost', event.target.value) - }} - /> - - - { - portForwardingService.setPortForwardValue(id, 'targetPort', event.target.valueAsNumber) - }} - /> - - - ) - } -) diff --git a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forwarding-control.module.css b/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forwarding-control.module.css deleted file mode 100644 index 50e3bbf33a..0000000000 --- a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forwarding-control.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.portForwardingControl { - overflow: hidden; - - .portForwardItem, - .placeholder { - min-width: 635px; - } - - svg { - box-sizing: content-box; - } -} diff --git a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forwarding-control.tsx b/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forwarding-control.tsx deleted file mode 100644 index c44aa8efc0..0000000000 --- a/ui/src/components/ui/device-control-panel/tabs/advanced-tab/port-forwarding-control/port-forwarding-control.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { observer } from 'mobx-react-lite' -import { useTranslation } from 'react-i18next' -import { useInjection } from 'inversify-react' -import { Flex, FormLayoutGroup, Placeholder, Separator, Spacing, Text } from '@vkontakte/vkui' -import { - Icon28InboxOutline, - Icon28ComputerOutline, - Icon28SmartphoneOutline, - Icon28ArrowRightOutline, - Icon20AddSquareOutline, - Icon20ShuffleOutline, -} from '@vkontakte/icons' - -import { ContentCard } from '@/components/lib/content-card' -import { ConditionalRender } from '@/components/lib/conditional-render' - -import { CONTAINER_IDS } from '@/config/inversify/container-ids' - -import { PortForwardItem } from './port-forward-item' - -import styles from './port-forwarding-control.module.css' - -export const PortForwardingControl = observer(({ className }: { className?: string }) => { - const { t } = useTranslation() - - const portForwardingService = useInjection(CONTAINER_IDS.portForwardingService) - - return ( - } - afterTooltipText={t('Add')} - before={} - className={className} - title={t('Port Forwarding')} - onAfterButtonClick={() => portForwardingService.addPortForward()} - > -
- - - - {t('Device')} - - - - - {t('Host')} - - - - - - {portForwardingService.portForwards.map((item) => ( - - ))} - - - } noPadding> - {t('Empty')} - - -
-
- ) -}) diff --git a/ui/src/components/ui/device-table/cells/device-status-cell.tsx b/ui/src/components/ui/device-table/cells/device-status-cell.tsx index 4deb689655..58cdc2d5c3 100644 --- a/ui/src/components/ui/device-table/cells/device-status-cell.tsx +++ b/ui/src/components/ui/device-table/cells/device-status-cell.tsx @@ -15,19 +15,16 @@ import type { ListDevice } from '@/types/list-device.type' type DeviceStatusCellProps = { serial: ListDevice['serial'] - channel: ListDevice['channel'] deviceState: DeviceState } -export const DeviceStatusCell = memo(({ serial, channel, deviceState }: DeviceStatusCellProps) => { +export const DeviceStatusCell = memo(({ serial, deviceState }: DeviceStatusCellProps) => { const { t } = useTranslation() const deviceDisconnection = useInjection(CONTAINER_IDS.deviceDisconnection) const onStopUsing = () => { - if (!channel) return - - deviceDisconnection.stopUsingDevice(serial, channel) + deviceDisconnection.stopUsingDevice(serial) } return ( diff --git a/ui/src/components/ui/device-table/columns.tsx b/ui/src/components/ui/device-table/columns.tsx index 58fdb06c2d..03119b7e80 100644 --- a/ui/src/components/ui/device-table/columns.tsx +++ b/ui/src/components/ui/device-table/columns.tsx @@ -118,9 +118,9 @@ export const DEVICE_COLUMNS = [ filterFn: startsWithFilter, sortingFn: deviceStatusSorting, cell: ({ getValue, row }) => { - const { serial, channel } = row.original + const { serial } = row.original - return + return }, }), /* NOTE: OS & Hardware Group */ diff --git a/ui/src/components/ui/device/device-screen/device-screen.tsx b/ui/src/components/ui/device/device-screen/device-screen.tsx index 36f518944c..e116913b05 100644 --- a/ui/src/components/ui/device/device-screen/device-screen.tsx +++ b/ui/src/components/ui/device/device-screen/device-screen.tsx @@ -1,5 +1,6 @@ import { useInjection } from 'inversify-react' -import { useEffect, useRef, useState } from 'react' +import { observer } from 'mobx-react-lite' +import { useRef } from 'react' import { Spinner } from '@vkontakte/vkui' import { ConditionalRender } from '@/components/lib/conditional-render' @@ -17,27 +18,25 @@ enum DeviceType { TIZEN, } -export const DeviceScreen = () => { +const resolveDeviceType = ( + manufacturer: string | undefined, + platform: string | undefined +): DeviceType => { + if (!manufacturer && !platform) return DeviceType.FETCHING + + if (manufacturer === 'Apple') return DeviceType.APPLE + + if (platform === 'Tizen') return DeviceType.TIZEN + + return DeviceType.ANDROID +} + +export const DeviceScreen = observer(() => { const canvasWrapperRef = useRef(null) - const deviceScreenStore = useInjection(CONTAINER_IDS.deviceScreenStore) - const [deviceType, setDeviceType] = useState(DeviceType.FETCHING) - - useEffect(() => { - deviceScreenStore.init().then(() => { - const device = deviceScreenStore.getDevice - - return ( - device && - setDeviceType( - device.manufacturer === 'Apple' - ? DeviceType.APPLE - : device.platform === 'Tizen' - ? DeviceType.TIZEN - : DeviceType.ANDROID - ) - ) - }) - }, []) + const deviceBySerialStore = useInjection(CONTAINER_IDS.deviceBySerialStore) + + const { data: device } = deviceBySerialStore.deviceQueryResult() + const deviceType = resolveDeviceType(device?.manufacturer, device?.platform) return (
@@ -54,4 +53,4 @@ export const DeviceScreen = () => {
) -} +}) diff --git a/ui/src/components/ui/device/device-top-bar/device-top-bar.tsx b/ui/src/components/ui/device/device-top-bar/device-top-bar.tsx index 245becd202..5dcecb3713 100644 --- a/ui/src/components/ui/device/device-top-bar/device-top-bar.tsx +++ b/ui/src/components/ui/device/device-top-bar/device-top-bar.tsx @@ -94,9 +94,9 @@ export const DeviceTopBar = observer(() => { title={t('Warning')} onClose={() => setIsConfirmationOpen(false)} onOk={async () => { - if (!device?.channel || !device?.serial) return + if (!device?.serial) return - await deviceDisconnection.stopUsingDevice(device.serial, device.channel) + await deviceDisconnection.stopUsingDevice(device.serial) navigate(getMainRoute(), { replace: true }) }} diff --git a/ui/src/components/ui/modals/error-modal.tsx b/ui/src/components/ui/modals/error-modal.tsx index d0fc17a09d..2638847544 100644 --- a/ui/src/components/ui/modals/error-modal.tsx +++ b/ui/src/components/ui/modals/error-modal.tsx @@ -37,9 +37,9 @@ export const ErrorModal = ({ ...props }: Omit { - if (!device?.channel || !device?.serial) return + if (!device?.serial) return - deviceDisconnection.stopUsingDevice(device.serial, device.channel) + deviceDisconnection.stopUsingDevice(device.serial) navigate(getDevicesRoute(), { replace: true }) }} diff --git a/ui/src/config/inversify/container-ids.ts b/ui/src/config/inversify/container-ids.ts index c80913848a..c98921a061 100644 --- a/ui/src/config/inversify/container-ids.ts +++ b/ui/src/config/inversify/container-ids.ts @@ -30,7 +30,6 @@ import type { SaveLogsService } from '@/services/save-logs-service/save-logs-ser import type { GroupItemService } from '@/services/group-item-service/group-item-service' import type { DeviceScreenStore } from '@/store/device-screen-store/device-screen-store' import type { LogsTrackerService } from '@/services/logs-tracker-service/logs-tracker-service' -import type { PortForwardingService } from '@/services/port-forwarding-service/port-forwarding-service' import type { ApplicationInstallationService } from '@/services/application-installation/application-installation-service' import type { TeamSettingsService } from '@/services/team-settings-service' import type { TeamItemService } from '@/services/team-item-service' @@ -68,7 +67,6 @@ export const CONTAINER_IDS = { shellSettingsService: Symbol.for('ShellSettingsService') as interfaces.ServiceIdentifier, groupSettingsService: Symbol.for('GroupSettingsService') as interfaces.ServiceIdentifier, teamSettingsService: Symbol.for('TeamSettingsService') as interfaces.ServiceIdentifier, - portForwardingService: Symbol.for('PortForwardingService') as interfaces.ServiceIdentifier, deviceSettingsService: Symbol.for('DeviceSettingsService') as interfaces.ServiceIdentifier, deviceLifecycleService: Symbol.for('DeviceLifecycleService') as interfaces.ServiceIdentifier, factoryTransactionService: Symbol.for( diff --git a/ui/src/config/inversify/create-device-container.ts b/ui/src/config/inversify/create-device-container.ts index cf07e2491a..25b2766fc7 100644 --- a/ui/src/config/inversify/create-device-container.ts +++ b/ui/src/config/inversify/create-device-container.ts @@ -9,7 +9,6 @@ import { ScalingService } from '@/services/scaling-service/scaling-service' import { DeviceLifecycleService } from '@/services/device-lifecycle-service' import { KeyboardService } from '@/services/keyboard-service/keyboard-service' import { SaveLogsService } from '@/services/save-logs-service/save-logs-service' -import { PortForwardingService } from '@/services/port-forwarding-service/port-forwarding-service' import { ApplicationInstallationService } from '@/services/application-installation/application-installation-service' import { LinkOpenerStore } from '@/store/link-opener-store' @@ -27,8 +26,8 @@ import { DeviceScreenStore } from '@/store/device-screen-store/device-screen-sto */ export const createDeviceContainer = (serial: string): Container => { /* NOTE: - Inversify-react automatically establishes a hierarchy of containers - (https://github.com/inversify/InversifyJS/blob/master/wiki/hierarchical_di.md) + Inversify-react automatically establishes a hierarchy of containers + (https://github.com/inversify/InversifyJS/blob/master/wiki/hierarchical_di.md) within the React tree when multiple Providers are used. This means that if the device container lacks bindings, it passes the request up to its parent container (in our case, globalContainer) @@ -51,7 +50,6 @@ export const createDeviceContainer = (serial: string): Container => { deviceContainer.bind(CONTAINER_IDS.deviceControlStore).to(DeviceControlStore) deviceContainer.bind(CONTAINER_IDS.deviceBySerialStore).to(DeviceBySerialStore) deviceContainer.bind(CONTAINER_IDS.fileExplorerService).to(FileExplorerService) - deviceContainer.bind(CONTAINER_IDS.portForwardingService).to(PortForwardingService) deviceContainer.bind(CONTAINER_IDS.deviceLifecycleService).to(DeviceLifecycleService) deviceContainer.bind(CONTAINER_IDS.applicationInstallationService).to(ApplicationInstallationService) diff --git a/ui/src/services/booking-service.ts b/ui/src/services/booking-service.ts index efe689da2b..8598a78959 100644 --- a/ui/src/services/booking-service.ts +++ b/ui/src/services/booking-service.ts @@ -32,9 +32,9 @@ export class BookingService { async reBookDevice(): Promise { const { data: device } = await this.deviceBySerialStore.refetch() - if (!device || !device.channel || !device.serial) return + if (!device || !device.serial) return - await this.groupService.invite(device.serial, device.channel, device.group) + await this.groupService.invite(device.serial, device.group) if (device.statusChangedAt) { this.setTime(device.statusChangedAt, device?.bookedBefore || 0) diff --git a/ui/src/services/core/device-control-service/device-control-service.ts b/ui/src/services/core/device-control-service/device-control-service.ts index 5ff93c15da..1dc2a3c98b 100644 --- a/ui/src/services/core/device-control-service/device-control-service.ts +++ b/ui/src/services/core/device-control-service/device-control-service.ts @@ -13,7 +13,6 @@ import type { FSListMessage } from '@/types/fs-list-message.type' import type { TransactionFactory } from '@/types/transaction-factory.type' import type { DeviceBySerialStore } from '@/store/device-by-serial-store' import type { TouchDownArgs, TouchMoveArgs, TouchMoveIosArgs } from './types' -import type { PortForwardEntry } from '@/services/port-forwarding-service/types' import type { InitializeTransactionReturn, InstallOptions } from '@/services/core/transaction-service/types' export class DeviceControlService { @@ -284,28 +283,6 @@ export class DeviceControlService { return this.sendTwoWay('logcat.stop') } - testForward({ targetHost, targetPort }: PortForwardEntry): Promise { - return this.sendTwoWay('forward.test', { - targetHost, - targetPort, - }) - } - - createForward({ id, devicePort, targetHost, targetPort }: PortForwardEntry): Promise { - return this.sendTwoWay('forward.create', { - id, - devicePort, - targetHost, - targetPort, - }) - } - - removeForward({ id }: PortForwardEntry): Promise { - return this.sendTwoWay('forward.remove', { - id, - }) - } - shell(command: string): Promise { return this.sendTwoWay('shell.command', { command, @@ -316,7 +293,7 @@ export class DeviceControlService { private sendOneWay(action: string, data?: T): void { const { data: device } = this.deviceBySerialStore.deviceQueryResult() - socket.emit(action, device?.channel, data) + socket.emit(action, device?.serial, data) } private async sendTwoWay(action: string, data?: T): Promise> { @@ -327,7 +304,7 @@ export class DeviceControlService { const platformSpecificAction = device?.manufacturer === 'Apple' ? `${action}Ios` : action - socket.emit(platformSpecificAction, device?.channel, initializeTransaction.channel, data) + socket.emit(platformSpecificAction, device?.serial, initializeTransaction.channel, data) return initializeTransaction } diff --git a/ui/src/services/group-service.ts b/ui/src/services/group-service.ts index 373405f143..8007cc125f 100644 --- a/ui/src/services/group-service.ts +++ b/ui/src/services/group-service.ts @@ -13,7 +13,7 @@ const MILLISECONDS_IN_MINUTE = 1000 * 60 export class GroupService { constructor(@inject(CONTAINER_IDS.factoryTransactionService) private transactionServiceFactory: TransactionFactory) {} - invite(serial: string, channel: string, deviceGroup?: DeviceGroup): Promise { + invite(serial: string, deviceGroup?: DeviceGroup): Promise { /* NOTE: 1 for Infinity */ let timeout = 1 @@ -28,7 +28,7 @@ export class GroupService { const transaction = this.transactionServiceFactory() const { channel: transactionChannel, donePromise: transactionEndPromise } = transaction.initializeTransaction() const invite = (): void => - socket.emit('group.invite', channel, transactionChannel, { + socket.emit('group.invite', serial, transactionChannel, { requirements: { serial: { value: serial, @@ -47,11 +47,11 @@ export class GroupService { return transactionEndPromise } - kick(serial: string, channel: string): Promise { + kick(serial: string): Promise { const transaction = this.transactionServiceFactory() const { channel: transactionChannel, donePromise: transactionEndPromise } = transaction.initializeTransaction() - socket.emit('group.kick', channel, transactionChannel, { + socket.emit('group.kick', serial, transactionChannel, { requirements: { serial: { value: serial, diff --git a/ui/src/services/port-forwarding-service/port-forwarding-service.ts b/ui/src/services/port-forwarding-service/port-forwarding-service.ts deleted file mode 100644 index a62e76df00..0000000000 --- a/ui/src/services/port-forwarding-service/port-forwarding-service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { makeAutoObservable } from 'mobx' -import { inject, injectable } from 'inversify' - -import { CONTAINER_IDS } from '@/config/inversify/container-ids' -import { DeviceControlStore } from '@/store/device-control-store' -import { deviceConnectionRequired } from '@/config/inversify/decorators' - -import type { PortForwardEntry } from './types' - -@injectable() -@deviceConnectionRequired() -export class PortForwardingService { - reversePortForwards: Record = {} - - constructor(@inject(CONTAINER_IDS.deviceControlStore) private deviceControlStore: DeviceControlStore) { - makeAutoObservable(this) - - this.reversePortForwards['_default'] = this.createPortForward('_default') - } - - get portForwards(): PortForwardEntry[] { - return Object.values(this.reversePortForwards) - } - - get isPortForwardsEmpty(): boolean { - return this.portForwards.length === 0 - } - - setPortForwardValue(id: string, field: T, value: PortForwardEntry[T]): void { - this.reversePortForwards[id][field] = value - } - - addPortForward(): void { - const id = crypto.randomUUID() - - this.reversePortForwards[id] = this.createPortForward(id) - } - - removePortForward(id: string): void { - if (this.reversePortForwards[id].isEnabled) { - this.deviceControlStore.removeForward(this.reversePortForwards[id]) - } - - delete this.reversePortForwards[id] - } - - togglePortForward(id: string, isEnabled: boolean): void { - const portForward = this.reversePortForwards[id] - - this.setPortForwardValue(id, 'isEnabled', isEnabled) - - if (portForward.isEnabled) { - this.deviceControlStore.createForward(portForward) - } - - if (!portForward.isEnabled) { - this.deviceControlStore.removeForward(portForward) - } - } - - private createPortForward(id: string): PortForwardEntry { - return { - id, - targetHost: 'localhost', - targetPort: 8080, - devicePort: 8080, - isEnabled: false, - } - } -} diff --git a/ui/src/services/port-forwarding-service/types.ts b/ui/src/services/port-forwarding-service/types.ts deleted file mode 100644 index 2a87bcf0fe..0000000000 --- a/ui/src/services/port-forwarding-service/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type PortForwardEntry = { - id: string - devicePort: number - targetHost: string - targetPort: number - isEnabled: boolean -} diff --git a/ui/src/services/shell-settings-service.ts b/ui/src/services/shell-settings-service.ts index a979f21737..e0c65e1717 100644 --- a/ui/src/services/shell-settings-service.ts +++ b/ui/src/services/shell-settings-service.ts @@ -63,7 +63,7 @@ export class ShellSettingsService extends ListManagementService<'serial', ShellD const transaction = new TransactionService() const initializeTransaction = transaction.initializeTransaction() - socket.emit('shell.command', device.channel, initializeTransaction.channel, { + socket.emit('shell.command', device.serial, initializeTransaction.channel, { command: this.command, timeout: 60000, }) diff --git a/ui/src/store/device-connection.ts b/ui/src/store/device-connection.ts index e14f1fe52c..2fc98a0185 100644 --- a/ui/src/store/device-connection.ts +++ b/ui/src/store/device-connection.ts @@ -46,7 +46,7 @@ export class DeviceConnection { : 'Error' }) - await this.groupService.invite(this.serial, device.channel, device.group) + await this.groupService.invite(this.serial, device.group) this.settingsService.updateLastUsedDevice(this.serial) } catch (error) { diff --git a/ui/src/store/device-disconnection.ts b/ui/src/store/device-disconnection.ts index 8955c657e2..da0558affd 100644 --- a/ui/src/store/device-disconnection.ts +++ b/ui/src/store/device-disconnection.ts @@ -11,7 +11,7 @@ export class DeviceDisconnection { makeAutoObservable(this) } - async stopUsingDevice(serial: string, channel: string): Promise { - return this.groupService.kick(serial, channel) + async stopUsingDevice(serial: string): Promise { + return this.groupService.kick(serial) } } diff --git a/ui/src/store/device-screen-store/device-screen-store.ts b/ui/src/store/device-screen-store/device-screen-store.ts index 1bd3337c37..2c779c9093 100644 --- a/ui/src/store/device-screen-store/device-screen-store.ts +++ b/ui/src/store/device-screen-store/device-screen-store.ts @@ -1,6 +1,7 @@ import { t } from 'i18next' -import { makeAutoObservable, runInAction } from 'mobx' +import { autorun, makeAutoObservable, runInAction } from 'mobx' import { inject, injectable } from 'inversify' +import { backOff } from 'exponential-backoff' import { CONTAINER_IDS } from '@/config/inversify/container-ids' import { DeviceBySerialStore } from '@/store/device-by-serial-store' @@ -9,35 +10,49 @@ import { deviceConnectionRequired } from '@/config/inversify/decorators' import { authStore } from '@/store/auth-store' import type { ElementBoundSize, StartScreenStreamingMessage } from './types' -import type { Device } from '@/generated/types' + +class AuthError extends Error { + constructor() { + super('Unauthorized') + this.name = 'AuthError' + } +} @injectable() @deviceConnectionRequired() export class DeviceScreenStore { - private readonly websocketReconnectionInterval = 5000 // NOTE: 5s - private readonly websocketReconnectionMaxAttempts = 3 // NOTE: 5s * 3 -> 15s total delay private websocket: WebSocket | null = null - private websocketReconnecting = false - private websocketReconnectionAttempt = 0 - private websocketReconnectionTimeoutID: ReturnType | null = null + private backoffPromise: Promise | null = null private disposed = false private context: ImageBitmapRenderingContext | null = null private canvasWrapper: HTMLDivElement | null = null - private device: Device | null = null private showScreen = true private options = { autoScaleForRetina: true, density: Math.max(1, Math.min(1.5, devicePixelRatio || 1)), minScale: 0.36, } - private adjustedBoundSize = { + private adjustedBoundSize: ElementBoundSize = { width: 0, height: 0, } private screenRotation = 0 private isScreenStreamingJustStarted = false + /** + * Native (device-side framebuffer) dimensions. Seeded from the screen-streaming + * WebSocket's `start { realWidth, realHeight }` banner, and as a defence-in-depth + * fallback from the first decoded image frame. Independent of `device.display.*`, + * which can be transiently undefined right after device introduction. + */ + private nativeWidth = 0 + private nativeHeight = 0 + private hasNativeSize = false + + /** MobX autorun disposer, used to re-trigger connect when display.url becomes available. */ + private urlReactionDisposer: (() => void) | null = null + isAspectRatioModeLetterbox = false isScreenLoading = false isScreenRotated = false @@ -45,15 +60,10 @@ export class DeviceScreenStore { constructor(@inject(CONTAINER_IDS.deviceBySerialStore) private deviceBySerialStore: DeviceBySerialStore) { this.updateBounds = this.updateBounds.bind(this) this.messageListener = this.messageListener.bind(this) - this.openListener = this.openListener.bind(this) makeAutoObservable(this) } - get getDevice(): Device | null { - return this.device - } - get getCanvasWrapper(): HTMLDivElement | null { return this.canvasWrapper } @@ -66,16 +76,11 @@ export class DeviceScreenStore { this.isScreenLoading = value } - async init(): Promise { - this.device = await this.deviceBySerialStore.fetch() - } - async startScreenStreaming(canvas: HTMLCanvasElement, canvasWrapper: HTMLDivElement): Promise { runInAction(() => { this.setIsScreenLoading(true) }) - // NOTE: Prevents ws connection if stopScreenStreaming was called earlier if (this.disposed) { this.disposed = false @@ -85,16 +90,36 @@ export class DeviceScreenStore { this.context = canvas.getContext('bitmaprenderer') this.canvasWrapper = canvasWrapper - this.connectWebsocket() + // Watch for display.url becoming available; trigger (re)connection when it appears + // while we're not already connected. This decouples the streaming WS lifecycle + // from a possibly-stale initial fetch. + this.urlReactionDisposer?.() + this.urlReactionDisposer = autorun(() => { + const url = this.deviceBySerialStore.deviceQueryResult().data?.display?.url + + if (!url) return + + if (this.disposed) return + + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) return + + if (this.backoffPromise) return + + this.connectWithBackoff() + }) } stopScreenStreaming(): void { this.disposed = true - this.stopWebsocket() + this.backoffPromise = null + + this.urlReactionDisposer?.() + this.urlReactionDisposer = null - if (this.websocketReconnectionTimeoutID) { - clearTimeout(this.websocketReconnectionTimeoutID) - this.websocketReconnectionTimeoutID = null + if (this.websocket) { + this.websocket.onclose = null + this.websocket.close() + this.websocket = null } } @@ -129,14 +154,10 @@ export class DeviceScreenStore { private shouldUpdateScreen(): boolean { return Boolean( - // NO if the user has disabled the screen. this.showScreen && - // NO if the page is not visible (e.g. background tab). document.visibilityState === 'visible' && - // NO if we don't have a connection yet. this.websocket && this.websocket.readyState === WebSocket.OPEN - // YES otherwise ) } @@ -158,25 +179,35 @@ export class DeviceScreenStore { } } + /** + * Compute the bound size the minicap projection should be re-encoded to. + * + * If the native (framebuffer) dimensions are not yet known (banner / first frame + * haven't arrived), we send the raw wrapper size multiplied by density. minicap + * accepts arbitrary projections, and as soon as `nativeWidth`/`nativeHeight` are + * known the next call clamps to `minScale` of the native size. + * + * This function intentionally never throws so that the WS auth_success path + * and ResizeObserver callbacks remain side-effect-safe even when `device.display` + * is transiently undefined right after device introduction. + */ private adjustBoundedSize(width: number, height: number): ElementBoundSize { - if (!this.device?.display?.width || !this.device?.display?.height) { - throw new Error('No display width or height') - } - - const scaledWidth = this.device.display.width * this.options.minScale - const scaledHeight = this.device.display.height * this.options.minScale - let sw = width * this.options.density let sh = height * this.options.density - if (sw < scaledWidth) { - sw *= scaledWidth / sw - sh *= scaledWidth / sh - } + if (this.hasNativeSize && this.nativeWidth > 0 && this.nativeHeight > 0) { + const scaledWidth = this.nativeWidth * this.options.minScale + const scaledHeight = this.nativeHeight * this.options.minScale + + if (sw < scaledWidth) { + sw *= scaledWidth / sw + sh *= scaledWidth / sh + } - if (sh < scaledHeight) { - sw *= scaledHeight / sw - sh *= scaledHeight / sh + if (sh < scaledHeight) { + sw *= scaledHeight / sw + sh *= scaledHeight / sh + } } return { @@ -203,6 +234,20 @@ export class DeviceScreenStore { return this.screenRotation === 90 || this.screenRotation === 270 } + private setNativeSize(width: number, height: number): void { + if (!width || !height) return + + if (this.isRotated()) { + this.nativeWidth = height + this.nativeHeight = width + } else { + this.nativeWidth = width + this.nativeHeight = height + } + + this.hasNativeSize = true + } + private updateImageArea(imageWidth: number, imageHeight: number): void { if (!this.context) { throw new Error('Context is not set') @@ -228,52 +273,115 @@ export class DeviceScreenStore { this.isScreenRotated = false } + if (!this.hasNativeSize) { + this.setNativeSize(imageWidth, imageHeight) + + // Re-send a size message now that we know the native dimensions. + try { + this.updateBounds() + } catch { + /* canvasWrapper not yet set, harmless */ + } + } + this.determineAspectRatioMode() } - private connectWebsocket(): void { - if (!this.device?.display?.url) { - throw new Error('No display url') - } + private connectWebsocket(): Promise { + return new Promise((resolve, reject) => { + const url = this.deviceBySerialStore.deviceQueryResult().data?.display?.url - if (!authStore.jwt) { - console.warn('No JWT token available in authStore') - throw new Error('Authentication token required') - } + if (!url) { + reject(new Error('No display url')) - // Pass JWT token securely via WebSocket subprotocol - this.websocket = new WebSocket(this.device.display.url, `access_token.${authStore.jwt}`) + return + } - this.websocket.binaryType = 'blob' - this.websocket.onopen = this.openListener.bind(this) - this.websocket.onmessage = this.messageListener.bind(this) - this.websocket.onerror = this.errorListener.bind(this) - this.websocket.onclose = this.closeListener.bind(this) - } + if (!authStore.jwt) { + reject(new Error('Authentication token required')) - private stopWebsocket(): void { - if (this.websocket) { - this.websocket.close() - this.websocket = null - } + return + } + + const ws = new WebSocket(url, `access_token.${authStore.jwt}`) + + ws.binaryType = 'blob' + + ws.onopen = (): void => { + this.websocket = ws + ws.onmessage = this.messageListener.bind(this) + ws.onerror = (): void => {} + + ws.onclose = this.handleUnexpectedClose.bind(this) + + this.isScreenStreamingJustStarted = true + resolve() + } + + ws.onerror = (): void => {} + + ws.onclose = (event: CloseEvent): void => { + if (event.code === 1008) { + reject(new AuthError()) + + return + } + + reject(new Error(`WebSocket closed before opening: ${event.code}`)) + } + }) } - private reconnectWebsocket(): void { - // NOTE: No need reconnect if it is already in progress - if (this.websocketReconnecting || this.websocketReconnectionTimeoutID) return + private connectWithBackoff(): Promise { + if (this.backoffPromise) return this.backoffPromise - this.websocketReconnecting = true - this.websocketReconnectionAttempt += 1 - this.connectWebsocket() + this.backoffPromise = backOff(() => this.connectWebsocket(), { + numOfAttempts: 8, + startingDelay: 1000, + maxDelay: 16000, + jitter: 'full', + retry: (err) => { + if (this.disposed) return false + + if (err instanceof AuthError) return false + + return true + }, + }) + .catch((err) => { + if (this.disposed) return + + runInAction(() => { + if (err instanceof AuthError) { + deviceErrorModalStore.setError(t('Unauthorized')) + } else { + deviceErrorModalStore.setError(t('Service is currently unavailable')) + } + }) + }) + .finally(() => { + this.backoffPromise = null + }) + + return this.backoffPromise } - private openListener(): void { - if (this.websocketReconnecting) { - this.websocketReconnecting = false - this.websocketReconnectionAttempt = 0 + private handleUnexpectedClose(event: CloseEvent): void { + this.websocket = null + + runInAction(() => { + this.setIsScreenLoading(true) + }) + + if (event.code === 1008) { + deviceErrorModalStore.setError(t('Unauthorized')) + + return } - this.isScreenStreamingJustStarted = true + if (!event.wasClean && !this.disposed) { + this.connectWithBackoff() + } } private messageListener(message: MessageEvent): void { @@ -297,12 +405,9 @@ export class DeviceScreenStore { } if (message.data === 'secure_on') { - // NOTE: The current view is marked secure and cannot be viewed remotely - return } - // Handle authentication messages if (typeof message.data === 'string') { try { const authMessage = JSON.parse(message.data) @@ -311,9 +416,17 @@ export class DeviceScreenStore { console.info('WebSocket authentication successful') if (this.shouldUpdateScreen()) { - this.updateBounds() + // IMPORTANT: signal interest first so the server emits the `start` banner. + // The banner contains real device-side dimensions, after which a + // subsequent updateBounds() will apply the correct minScale floor. this.onScreenInterestGained() + try { + this.updateBounds() + } catch (err) { + console.warn('updateBounds skipped:', err) + } + return } @@ -334,38 +447,34 @@ export class DeviceScreenStore { const startRegex = /^start / - if (startRegex.test(message.data)) { - const startData: StartScreenStreamingMessage = JSON.parse(message.data.replace(startRegex, '')) - - this.isScreenStreamingJustStarted = true + if (startRegex.test(message.data as string)) { + const payload = (message.data as string).replace(startRegex, '') + let startData: StartScreenStreamingMessage | null = null - this.screenRotation = startData.orientation - } - } - - private errorListener(): void {} - - private closeListener(event: CloseEvent): void { - this.setIsScreenLoading(true) - this.websocketReconnecting = false + try { + startData = JSON.parse(payload) as StartScreenStreamingMessage | null + } catch { + startData = null + } - if (event.code === 1008) { - deviceErrorModalStore.setError(t('Unauthorized')) + this.isScreenStreamingJustStarted = true - return - } + if (startData) { + this.screenRotation = startData.orientation ?? this.screenRotation - if (!event.wasClean && this.websocketReconnectionAttempt < this.websocketReconnectionMaxAttempts) { - this.websocketReconnectionTimeoutID = setTimeout(() => { - this.websocketReconnectionTimeoutID = null - this.reconnectWebsocket() - }, this.websocketReconnectionInterval) + const sourceWidth = startData.realWidth || startData.virtualWidth || 0 + const sourceHeight = startData.realHeight || startData.virtualHeight || 0 - return - } + if (sourceWidth && sourceHeight) { + this.setNativeSize(sourceWidth, sourceHeight) - if (this.websocketReconnectionAttempt >= this.websocketReconnectionMaxAttempts) { - deviceErrorModalStore.setError(t('Service is currently unavailable')) + try { + this.updateBounds() + } catch { + /* empty */ + } + } + } } } } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 91d36f544f..cd64213f9f 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], reporters: ['verbose'], setupFiles: './vitest-setup.ts', globalSetup: './test-global-setup.ts', diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000000..4d62125f59 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import {defineConfig} from 'vitest/config' + +// Dev-only unit test harness. Scoped to pure wire-layer modules so it never +// touches ZMQ, MongoDB or the network. Does not affect the production build +// (tsconfig.node.json) in any way. +export default defineConfig({ + test: { + include: ['test/unit/**/*.test.ts'], + environment: 'node', + }, +})