Add platform AI Workspace Hub

This commit is contained in:
Codex 2026-05-30 13:28:23 +03:00
parent e605e95920
commit 469657d526
13 changed files with 1403 additions and 7 deletions

View File

@ -18,6 +18,8 @@ services:
condition: service_started condition: service_started
notification-core: notification-core:
condition: service_started condition: service_started
ai-workspace-hub:
condition: service_started
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
@ -126,6 +128,24 @@ services:
notification-postgres: notification-postgres:
condition: service_healthy condition: service_healthy
ai-workspace-hub:
image: nodedc/ai-workspace-hub:local
build:
context: ../services/ai-workspace-hub
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
NODE_ENV: production
PORT: 18081
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:-dev-ai-workspace-hub-token}
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
expose:
- "18081"
ports:
- "${AI_WORKSPACE_HUB_HOST_BIND:-127.0.0.1:18081}:18081"
volumes: volumes:
authentik-database: authentik-database:
authentik-data: authentik-data:

View File

@ -56,3 +56,6 @@ NOTIFICATION_PG_DB=nodedc_notifications
NOTIFICATION_PG_USER=nodedc_notifications NOTIFICATION_PG_USER=nodedc_notifications
NOTIFICATION_PG_PASS=replace-with-random-synology-secret NOTIFICATION_PG_PASS=replace-with-random-synology-secret
NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185 NODEDC_NOTIFICATION_CORE_URL=http://notification-core:5185
AI_WORKSPACE_HUB_TOKEN=replace-with-random-synology-secret
AI_WORKSPACE_HUB_HOST_BIND=0.0.0.0:18081

View File

@ -7,7 +7,7 @@
- Не выполнять `docker stop`, `docker restart`, `docker compose down`, `docker system prune` для старых проектов. - Не выполнять `docker stop`, `docker restart`, `docker compose down`, `docker system prune` для старых проектов.
- Новый compose project: `nodedc-platform`. - Новый compose project: `nodedc-platform`.
- Новая папка на NAS: `/volume1/docker/nodedc-platform`. - Новая папка на NAS: `/volume1/docker/nodedc-platform`.
- Внутренний HTTP edge использует `18080`, Tasker upstream — `18090`, Ops Agents Gateway upstream — `18190`. - Внутренний HTTP edge использует `18080`, AI Workspace Hub — `18081`, Tasker upstream — `18090`, Ops Agents Gateway upstream — `18190`.
- Старые порты `9000` и `5678` заняты старым `nodedc-demo` и не используются. - Старые порты `9000` и `5678` заняты старым `nodedc-demo` и не используются.
## Текущие внешние домены ## Текущие внешние домены
@ -17,10 +17,13 @@ https://id.nodedc.ru -> Authentik
https://hub.nodedc.ru -> Launcher / Hub https://hub.nodedc.ru -> Launcher / Hub
https://ops.nodedc.ru -> Tasker / Operational Core https://ops.nodedc.ru -> Tasker / Operational Core
https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP https://ops-agents.nodedc.ru -> Ops Agents Gateway / MCP
https://ai-hub.nodedc.ru -> AI Workspace Hub / WebSocket relay
``` ```
`id.nodedc.ru` is the user-facing OIDC/login host. Authentik Admin is intentionally not exposed through this public host; `/if/admin/*` returns `404` there. `id.nodedc.ru` is the user-facing OIDC/login host. Authentik Admin is intentionally not exposed through this public host; `/if/admin/*` returns `404` there.
`ai-hub.nodedc.ru` is intentionally routed by Synology DSM Reverse Proxy directly to `172.22.0.222:18081`, because this endpoint must carry WebSocket Upgrade traffic for remote Codex agents.
В `Caddyfile.http` эти домены проксируются через локальный HTTP edge, но upstream получает `X-Forwarded-Proto: https` и `X-Forwarded-Port: 443`. В `Caddyfile.http` эти домены проксируются через локальный HTTP edge, но upstream получает `X-Forwarded-Proto: https` и `X-Forwarded-Port: 443`.
## Локальные домены для первичной проверки ## Локальные домены для первичной проверки
@ -49,9 +52,9 @@ http://task.nas.nodedc:18090
## Что входит ## Что входит
- `docker-compose.platform-http.yml` поднимает новый Authentik, Launcher, Notification Core и Caddy edge. - `docker-compose.platform-http.yml` поднимает новый Authentik, Launcher, Notification Core, AI Workspace Hub и Caddy edge.
- `Caddyfile.http` маршрутизирует локальные `auth/auth-admin/launcher/task.nas.nodedc`, IP fallback `172.22.0.222` для Authentik Admin и внешние `id/hub/ops.nodedc.ru`. - `Caddyfile.http` маршрутизирует локальные `auth/auth-admin/launcher/task.nas.nodedc`, IP fallback `172.22.0.222` для Authentik Admin и внешние `id/hub/ops.nodedc.ru`.
- `deploy-current.sh` синхронизирует compose, Caddyfile, Notification Core source и опционально Launcher source в NAS mount. Authentik templates синхронизируются только при явном `SYNC_AUTHENTIK_TEMPLATES=1`. - `deploy-current.sh` синхронизирует compose, Caddyfile, Notification Core source, AI Workspace Hub source и опционально Launcher source в NAS mount. Authentik templates синхронизируются только при явном `SYNC_AUTHENTIK_TEMPLATES=1`.
- `backup-current.sh` делает snapshot Launcher runtime/uploads/Auth templates/config и готовит команду `pg_dump` для Authentik Postgres. - `backup-current.sh` делает snapshot Launcher runtime/uploads/Auth templates/config и готовит команду `pg_dump` для Authentik Postgres.
- Tasker поднимается отдельным compose из `NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` на порту `18090`. - Tasker поднимается отдельным compose из `NODEDC_TASKMANAGER/plane-app/docker-compose.yaml` на порту `18090`.
- Ops Agents Gateway поднимается отдельным compose из `NODEDC_TASKMANAGER_CODEXAPI/docker-compose.synology.yml` на `172.22.0.222:18190`; Synology reverse proxy должен вести `ops-agents.nodedc.ru` на этот порт, а не на `18090`. - Ops Agents Gateway поднимается отдельным compose из `NODEDC_TASKMANAGER_CODEXAPI/docker-compose.synology.yml` на `172.22.0.222:18190`; Synology reverse proxy должен вести `ops-agents.nodedc.ru` на этот порт, а не на `18090`.
@ -117,6 +120,7 @@ GATEWAY_REPO=/Users/dcconstructions/Downloads/mnt/data/NODEDC_TASKMANAGER_CODEXA
- Platform compose/Caddy. - Platform compose/Caddy.
- Notification Core source в `/volume1/docker/nodedc-platform/platform/notification-core`. - Notification Core source в `/volume1/docker/nodedc-platform/platform/notification-core`.
- AI Workspace Hub source в `/volume1/docker/nodedc-platform/platform/ai-workspace-hub`.
- Authentik templates только при `SYNC_AUTHENTIK_TEMPLATES=1`; по умолчанию они не трогаются, чтобы лёгкий Hub deploy не уносил экспериментальную тему/брендинг в prod. - Authentik templates только при `SYNC_AUTHENTIK_TEMPLATES=1`; по умолчанию они не трогаются, чтобы лёгкий Hub deploy не уносил экспериментальную тему/брендинг в prod.
- Launcher source в `/volume1/docker/nodedc-platform/launcher/source`. - Launcher source в `/volume1/docker/nodedc-platform/launcher/source`.
- Tasker `plane-app/docker-compose.yaml` и, если задан `TASKER_CHANGED_BASE`, только изменённые source-файлы из диапазона `TASKER_CHANGED_BASE..HEAD`. - Tasker `plane-app/docker-compose.yaml` и, если задан `TASKER_CHANGED_BASE`, только изменённые source-файлы из диапазона `TASKER_CHANGED_BASE..HEAD`.
@ -148,7 +152,7 @@ cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \ sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --force-recreate --no-deps launcher up -d --force-recreate --no-deps ai-workspace-hub launcher
``` ```
После такого deploy проверить `healthz`, запись в launcher storage/uploads и сценарий пользователя без аппрува: сохранение аватара не должно показывать экран `Заявка ожидает подтверждения`. Дополнительно проверить, что live bundle больше не содержит старый pending gate: После такого deploy проверить `healthz`, запись в launcher storage/uploads и сценарий пользователя без аппрува: сохранение аватара не должно показывать экран `Заявка ожидает подтверждения`. Дополнительно проверить, что live bundle больше не содержит старый pending gate:

View File

@ -26,17 +26,25 @@ echo "== notification core image build =="
cd "${PLATFORM_DIR}/notification-core" cd "${PLATFORM_DIR}/notification-core"
"${DOCKER_BIN}" build --no-cache -t nodedc/notification-core:local . "${DOCKER_BIN}" build --no-cache -t nodedc/notification-core:local .
echo "== ai workspace hub image build =="
cd "${PLATFORM_DIR}/ai-workspace-hub"
"${DOCKER_BIN}" build --no-cache -t nodedc/ai-workspace-hub:local .
echo "== platform services recreate ==" echo "== platform services recreate =="
cd "${PLATFORM_DIR}" cd "${PLATFORM_DIR}"
"${DOCKER_BIN}" compose \ "${DOCKER_BIN}" compose \
--env-file "${ENV_FILE}" \ --env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \ -f "${COMPOSE_FILE}" \
up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core launcher up -d --force-recreate reverse-proxy authentik-server authentik-worker notification-postgres notification-core ai-workspace-hub launcher
echo "== notification core health check ==" echo "== notification core health check =="
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \ "${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
'wget -qSO- http://127.0.0.1:5185/healthz 2>&1 | head -n 25' 'wget -qSO- http://127.0.0.1:5185/healthz 2>&1 | head -n 25'
echo "== ai workspace hub health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== clear authentik global brand css ==" echo "== clear authentik global brand css =="
DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh" DOCKER_BIN="${DOCKER_BIN}" bash "${PLATFORM_DIR}/clear-authentik-brand-css.sh"

View File

@ -45,6 +45,7 @@ rsync_dir "${NAS_ROOT}/launcher/uploads/" "${BACKUP_DIR}/files/launcher/uploads/
rsync_dir "${NAS_ROOT}/authentik/custom-templates/" "${BACKUP_DIR}/files/authentik/custom-templates/" rsync_dir "${NAS_ROOT}/authentik/custom-templates/" "${BACKUP_DIR}/files/authentik/custom-templates/"
rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authentik/" rsync_dir "${NAS_ROOT}/platform/authentik/" "${BACKUP_DIR}/files/platform/authentik/"
rsync_dir "${NAS_ROOT}/platform/notification-core/" "${BACKUP_DIR}/files/platform/notification-core/" rsync_dir "${NAS_ROOT}/platform/notification-core/" "${BACKUP_DIR}/files/platform/notification-core/"
rsync_dir "${NAS_ROOT}/platform/ai-workspace-hub/" "${BACKUP_DIR}/files/platform/ai-workspace-hub/"
rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/" rsync_file "${NAS_ROOT}/platform/.env.synology" "${BACKUP_DIR}/files/platform/"
rsync_file "${NAS_ROOT}/platform/.env.synology.example" "${BACKUP_DIR}/files/platform/" rsync_file "${NAS_ROOT}/platform/.env.synology.example" "${BACKUP_DIR}/files/platform/"
@ -71,6 +72,7 @@ Contains:
- Authentik custom templates: authentik/custom-templates - Authentik custom templates: authentik/custom-templates
- Platform runtime config: platform/.env.synology, compose, Caddyfile - Platform runtime config: platform/.env.synology, compose, Caddyfile
- Notification Core source/config: platform/notification-core, platform compose/env - Notification Core source/config: platform/notification-core, platform compose/env
- AI Workspace Hub source/config: platform/ai-workspace-hub, platform compose/env
- Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override - Tasker runtime config: tasker/plane-app/.env.synology, compose, Synology override
- Ops Agents Gateway runtime config: ops-agents/.env, compose - Ops Agents Gateway runtime config: ops-agents/.env, compose

View File

@ -43,6 +43,14 @@ rsync -av --delete \
"${PLATFORM_REPO}/services/notification-core/" \ "${PLATFORM_REPO}/services/notification-core/" \
"${NAS_ROOT}/platform/notification-core/" "${NAS_ROOT}/platform/notification-core/"
mkdir -p "${NAS_ROOT}/platform/ai-workspace-hub"
rsync -av --delete \
--exclude='node_modules/' \
--exclude='.env' \
--exclude='.env.*' \
"${PLATFORM_REPO}/services/ai-workspace-hub/" \
"${NAS_ROOT}/platform/ai-workspace-hub/"
if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then if [[ "${SYNC_AUTHENTIK_TEMPLATES}" == "1" ]]; then
mkdir -p "${NAS_ROOT}/authentik/custom-templates" mkdir -p "${NAS_ROOT}/authentik/custom-templates"
rsync -av --delete \ rsync -av --delete \
@ -193,14 +201,14 @@ cd /volume1/docker/nodedc-platform/platform
sudo /usr/local/bin/docker compose \ sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --build --force-recreate --no-deps notification-core launcher up -d --build --force-recreate --no-deps ai-workspace-hub notification-core launcher
Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes: Optional Platform/Auth infra apply, only after deliberate compose/proxy/Auth templates changes:
sudo /usr/local/bin/docker compose \ sudo /usr/local/bin/docker compose \
--env-file /volume1/docker/nodedc-platform/platform/.env.synology \ --env-file /volume1/docker/nodedc-platform/platform/.env.synology \
-f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \ -f /volume1/docker/nodedc-platform/platform/docker-compose.platform-http.yml \
up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker notification-core launcher up -d --build --force-recreate --no-deps reverse-proxy authentik-server authentik-worker ai-workspace-hub notification-core launcher
After Authentik template rollout, clear global Brand custom CSS from the live DB. After Authentik template rollout, clear global Brand custom CSS from the live DB.
NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css: NODE.DC login CSS must be template-scoped, not stored in Brand.branding_custom_css:
@ -230,6 +238,8 @@ sudo /usr/local/bin/docker exec nodedc-platform-launcher-1 sh -lc '
curl -k -sS --compressed https://id.nodedc.ru/if/flow/default-authentication-flow/ \ curl -k -sS --compressed https://id.nodedc.ru/if/flow/default-authentication-flow/ \
| grep -aE 'syncFormFieldsBeforeSubmit|branding_custom_css = ""|hub.nodedc.ru|launcher.local|getLauncherBaseUrl|Запросить доступ' | grep -aE 'syncFormFieldsBeforeSubmit|branding_custom_css = ""|hub.nodedc.ru|launcher.local|getLauncherBaseUrl|Запросить доступ'
curl -fsS http://172.22.0.222:18081/healthz
id_admin_status="$( id_admin_status="$(
curl -k -sS -o /dev/null -w '%{http_code}' https://id.nodedc.ru/if/admin/ curl -k -sS -o /dev/null -w '%{http_code}' https://id.nodedc.ru/if/admin/
)" )"

View File

@ -19,6 +19,8 @@ services:
condition: service_started condition: service_started
notification-core: notification-core:
condition: service_started condition: service_started
ai-workspace-hub:
condition: service_started
extra_hosts: extra_hosts:
- "id.nodedc.ru:host-gateway" - "id.nodedc.ru:host-gateway"
- "hub.nodedc.ru:host-gateway" - "hub.nodedc.ru:host-gateway"
@ -100,6 +102,26 @@ services:
- identity - identity
- engine - engine
ai-workspace-hub:
image: nodedc/ai-workspace-hub:local
build:
context: ./ai-workspace-hub
restart: unless-stopped
env_file:
- ${NODEDC_SYNOLOGY_ENV_FILE:-.env.synology}
environment:
NODE_ENV: production
PORT: 18081
AI_WORKSPACE_HUB_TOKEN: ${AI_WORKSPACE_HUB_TOKEN:?ai workspace hub token required}
AI_WORKSPACE_HUB_WS_PATH: /api/ai-workspace/hub
expose:
- "18081"
ports:
- "${AI_WORKSPACE_HUB_HOST_BIND:-0.0.0.0:18081}:18081"
networks:
- edge
- engine
postgresql-authentik: postgresql-authentik:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped

View File

@ -55,6 +55,10 @@ echo "== notification core health check =="
"${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \ "${DOCKER_BIN}" exec nodedc-platform-notification-core-1 sh -lc \
'wget -qSO- http://127.0.0.1:5185/healthz 2>&1 | head -n 25' 'wget -qSO- http://127.0.0.1:5185/healthz 2>&1 | head -n 25'
echo "== ai workspace hub health check =="
"${DOCKER_BIN}" exec nodedc-platform-ai-workspace-hub-1 sh -lc \
'node -e '"'"'fetch("http://127.0.0.1:18081/healthz").then(async (response) => { console.log(await response.text()); process.exit(response.ok ? 0 : 1); }).catch((error) => { console.error(error); process.exit(1); })'"'"''
echo "== auth flow check ==" echo "== auth flow check =="
auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)" auth_flow="$(fetch_with_retry https://id.nodedc.ru/if/flow/default-authentication-flow/)"
printf '%s' "$auth_flow" \ printf '%s' "$auth_flow" \

View File

@ -0,0 +1,23 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS runner
ENV NODE_ENV=production
ENV PORT=18081
WORKDIR /app
COPY --from=deps /app/package.json /app/package-lock.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
USER node
EXPOSE 18081
CMD ["node", "src/server.mjs"]

View File

@ -0,0 +1,32 @@
# NODE.DC AI Workspace Hub
Public rendezvous and relay service for AI Workspace Codex workers.
The service is intentionally thin:
- Windows Codex agents connect outbound over WebSocket.
- Engine talks to the hub over internal HTTP endpoints.
- The hub routes requests, public reasoning events, final responses and stop commands by pairing code.
- The hub does not run Codex and does not store project files.
## Public WebSocket
```text
wss://ai-hub.nodedc.ru/api/ai-workspace/hub?pairingCode=<code>&machineName=<name>
```
Synology terminates TLS and proxies the request to this service over HTTP.
## Internal HTTP
All internal API routes require `Authorization: Bearer <AI_WORKSPACE_HUB_TOKEN>` when `AI_WORKSPACE_HUB_TOKEN` is configured.
```text
GET /healthz
GET /api/ai-workspace/hub/v1/agents/:pairingCode
GET /api/ai-workspace/hub/v1/agents/:pairingCode/events?since=0&limit=100
POST /api/ai-workspace/hub/v1/agents/:pairingCode/request
POST /api/ai-workspace/hub/v1/agents/:pairingCode/dispatch
```
`request` waits for the worker response. `dispatch` returns immediately and keeps events available for polling.

View File

@ -0,0 +1,865 @@
{
"name": "@nodedc/ai-workspace-hub",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@nodedc/ai-workspace-hub",
"version": "0.1.0",
"dependencies": {
"express": "^5.2.1",
"ws": "^8.18.3"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"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/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "@nodedc/ai-workspace-hub",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/server.mjs",
"dev": "node --watch src/server.mjs"
},
"dependencies": {
"express": "^5.2.1",
"ws": "^8.18.3"
}
}

View File

@ -0,0 +1,389 @@
import crypto from "node:crypto";
import { createServer } from "node:http";
import express from "express";
import { WebSocket, WebSocketServer } from "ws";
const DEFAULT_WS_PATH = "/api/ai-workspace/hub";
const MAX_EVENTS_PER_AGENT = 5000;
const MAX_REQUEST_META_PER_AGENT = 1000;
const config = readConfig();
const app = express();
const httpServer = createServer(app);
const wss = new WebSocketServer({ noServer: true });
const agentsByCode = new Map();
let eventSeq = 0;
app.disable("x-powered-by");
app.use(express.json({ limit: "1mb" }));
app.get("/healthz", (_req, res) => {
res.json({
ok: true,
service: "nodedc-ai-workspace-hub",
wsPath: config.wsPath,
agentsOnline: Array.from(agentsByCode.values()).filter(isAgentOnline).length,
internalApiConfigured: Boolean(config.internalAccessToken),
});
});
app.get("/api/ai-workspace/hub/v1/agents/:pairingCode", requireInternalApi, (req, res) => {
const agent = agentsByCode.get(cleanPairingCode(req.params.pairingCode));
res.json({ ok: true, agent: publicAgent(agent) });
});
app.get("/api/ai-workspace/hub/v1/agents/:pairingCode/events", requireInternalApi, (req, res) => {
const payload = readAgentEvents(req.params.pairingCode, {
since: req.query.since,
limit: req.query.limit,
});
res.json({ ok: true, ...payload });
});
app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/request", requireInternalApi, asyncRoute(async (req, res) => {
const command = cleanString(req.body?.command, 100);
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs, 30000);
const payload = await requestAgent(req.params.pairingCode, command, req.body?.payload || {}, timeoutMs, {
quiet: Boolean(req.body?.quiet),
});
res.json({ ok: true, payload });
}));
app.post("/api/ai-workspace/hub/v1/agents/:pairingCode/dispatch", requireInternalApi, (req, res) => {
const command = cleanString(req.body?.command, 100);
const timeoutMs = sanitizeTimeoutMs(req.body?.timeoutMs, 30000);
const dispatch = dispatchAgent(req.params.pairingCode, command, req.body?.payload || {}, timeoutMs, {
quiet: Boolean(req.body?.quiet),
});
res.json({ ok: true, requestId: dispatch.requestId });
});
app.use((error, _req, res, _next) => {
const status = Number(error?.status || 500);
res.status(status >= 400 && status < 600 ? status : 500).json({
ok: false,
error: String(error?.message || error || "internal_error"),
});
});
httpServer.on("upgrade", (req, socket, head) => {
let pathname = "";
try {
pathname = new URL(req.url || "/", "http://localhost").pathname;
} catch {}
if (pathname !== config.wsPath) return;
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
wss.on("connection", attachAgent);
const heartbeat = setInterval(() => {
for (const agent of agentsByCode.values()) {
try {
agent.socket.ping();
} catch {}
}
}, 30000);
httpServer.listen(config.port, "0.0.0.0", () => {
console.log(`NODE.DC AI Workspace Hub listening on http://0.0.0.0:${config.port}`);
console.log(`NODE.DC AI Workspace Hub ws path: ${config.wsPath}`);
});
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
function attachAgent(socket, req) {
const url = new URL(req.url || config.wsPath, "http://localhost");
const pairingCode = cleanPairingCode(url.searchParams.get("pairingCode"));
if (!pairingCode) {
socket.close(1008, "pairing_code_required");
return;
}
const previous = agentsByCode.get(pairingCode);
if (previous) {
settleAll(previous, Object.assign(new Error("agent_replaced"), { status: 503 }));
previous.socket.close(4000, "agent_replaced");
}
const now = new Date().toISOString();
const agent = {
socket,
pairingCode,
machineName: cleanString(url.searchParams.get("machineName"), 120),
host: "",
connectedAt: now,
lastSeenAt: now,
pending: new Map(),
requestMeta: previous?.requestMeta || new Map(),
events: previous?.events || [],
};
agentsByCode.set(pairingCode, agent);
pushAgentEvent(agent, "", { kind: "connected", message: "Bridge agent connected." });
socket.on("message", (raw) => handleAgentMessage(agent, raw));
socket.on("close", () => {
pushAgentEvent(agent, "", { kind: "disconnected", message: "Bridge agent disconnected." });
if (agentsByCode.get(pairingCode) === agent) agentsByCode.delete(pairingCode);
settleAll(agent, Object.assign(new Error("bridge_agent_offline"), { status: 503 }));
});
socket.on("error", () => {});
sendJson(socket, {
type: "ready",
pairingCode,
connectedAt: now,
});
}
function handleAgentMessage(agent, raw) {
agent.lastSeenAt = new Date().toISOString();
let message = null;
try {
message = JSON.parse(String(raw));
} catch {
return;
}
if (message?.type === "hello") {
agent.machineName = cleanString(message.machineName || agent.machineName, 120);
agent.host = cleanString(message.host, 120);
pushAgentEvent(agent, "", {
kind: "hello",
message: "Bridge agent hello received.",
cwd: cleanString(message.cwd, 500),
});
return;
}
if (message?.type === "event") {
const pending = message?.requestId ? agent.pending.get(String(message.requestId || "")) : null;
if (pending?.quiet) return;
pushAgentEvent(agent, message.requestId, message.event || message.payload || {});
return;
}
if (message?.type !== "response") return;
const requestId = String(message.requestId || "");
const pending = agent.pending.get(requestId);
if (!pending) return;
agent.pending.delete(requestId);
clearTimeout(pending.timeout);
if (!pending.quiet) {
pushAgentEvent(agent, requestId, {
kind: "response_received",
message: `Bridge response received after ${elapsedLabel(pending.startedAt)}.`,
});
}
if (message.ok === false) {
const error = new Error(String(message.error || "bridge_agent_failed"));
error.status = Number(message.status || 502);
if (!pending.quiet) pushAgentEvent(agent, requestId, { kind: "error", message: error.message });
pending.reject(error);
return;
}
if (!pending.quiet) pushAgentEvent(agent, requestId, { kind: "done", message: "Bridge request completed." });
pending.resolve(message.payload || { ok: true });
}
function requestAgent(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
return startAgentRequest(pairingCodeRaw, command, payload, timeoutMs, options).promise;
}
function dispatchAgent(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
const { requestId, promise } = startAgentRequest(pairingCodeRaw, command, payload, timeoutMs, options);
promise.catch(() => {});
return { requestId };
}
function startAgentRequest(pairingCodeRaw, command, payload = {}, timeoutMs = 30000, options = {}) {
const pairingCode = cleanPairingCode(pairingCodeRaw);
const agent = agentsByCode.get(pairingCode);
if (!agent || !isAgentOnline(agent)) {
const error = new Error("bridge_agent_offline");
error.status = 503;
throw error;
}
const requestId = crypto.randomUUID();
const quiet = Boolean(options?.quiet);
agent.requestMeta.set(requestId, requestMetaFromPayload(command, payload));
trimRequestMeta(agent);
const promise = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
agent.pending.delete(requestId);
const error = new Error("bridge_timeout");
error.status = 504;
if (!quiet) pushAgentEvent(agent, requestId, { kind: "timeout", command, message: "Bridge request timed out." });
reject(error);
}, timeoutMs);
const startedAt = Date.now();
agent.pending.set(requestId, { resolve, reject, timeout, startedAt, quiet });
if (!quiet) pushAgentEvent(agent, requestId, { kind: "engine_send", command, message: `Engine sent request: ${command}` });
sendJson(agent.socket, {
type: "request",
requestId,
command: String(command || ""),
payload,
quiet,
});
});
return { requestId, promise };
}
function readAgentEvents(pairingCodeRaw, options = {}) {
const pairingCode = cleanPairingCode(pairingCodeRaw);
const agent = agentsByCode.get(pairingCode);
const since = Number(options.since || 0);
const limit = Math.max(0, Math.min(2000, Number(options.limit || 100)));
const events = agent?.events || [];
const latestEventId = events.length ? events[events.length - 1].id : eventSeq;
return {
events: events.filter((event) => event.id > since).slice(-limit),
latestEventId,
online: Boolean(agent && isAgentOnline(agent)),
};
}
function requestMetaFromPayload(command, payload = {}) {
const context = payload?.context && typeof payload.context === "object" ? payload.context : {};
return {
command: cleanString(command, 80),
threadId: cleanString(payload?.threadId, 160),
threadTitle: cleanString(payload?.threadTitle, 240),
userMessage: cleanString(payload?.message, 12000),
remoteControlMode: cleanString(context.remoteControlMode, 40),
};
}
function pushAgentEvent(agent, requestId, rawEvent = {}) {
if (!agent) return null;
const now = new Date().toISOString();
const cleanRequestId = String(requestId || rawEvent.requestId || "");
const meta = cleanRequestId ? agent.requestMeta?.get(cleanRequestId) : null;
const event = {
id: ++eventSeq,
pairingCode: agent.pairingCode,
requestId: cleanRequestId,
threadId: rawEvent.threadId == null ? cleanString(meta?.threadId, 160) : cleanString(rawEvent.threadId, 160),
threadTitle: rawEvent.threadTitle == null ? cleanString(meta?.threadTitle, 240) : cleanString(rawEvent.threadTitle, 240),
userMessage: rawEvent.userMessage == null ? cleanString(meta?.userMessage, 12000) : cleanString(rawEvent.userMessage, 12000),
remoteControlMode: rawEvent.remoteControlMode == null ? cleanString(meta?.remoteControlMode, 40) : cleanString(rawEvent.remoteControlMode, 40),
kind: cleanString(rawEvent.kind || rawEvent.type || "event", 40),
stream: rawEvent.stream ? cleanString(rawEvent.stream, 40) : "",
text: rawEvent.text == null ? "" : cleanString(rawEvent.text, 12000),
message: rawEvent.message == null ? "" : cleanString(rawEvent.message, 12000),
command: rawEvent.command == null ? "" : cleanString(rawEvent.command, 1000),
cwd: rawEvent.cwd == null ? "" : cleanString(rawEvent.cwd, 500),
currentFile: rawEvent.currentFile == null ? "" : cleanString(rawEvent.currentFile, 1000),
eventsFile: rawEvent.eventsFile == null ? "" : cleanString(rawEvent.eventsFile, 1000),
at: rawEvent.at ? cleanString(rawEvent.at, 80) : now,
};
agent.events.push(event);
if (agent.events.length > MAX_EVENTS_PER_AGENT) {
agent.events.splice(0, agent.events.length - MAX_EVENTS_PER_AGENT);
}
return event;
}
function publicAgent(agent) {
if (!agent) return null;
return {
online: isAgentOnline(agent),
pairingCode: agent.pairingCode,
machineName: agent.machineName,
host: agent.host,
connectedAt: agent.connectedAt,
lastSeenAt: agent.lastSeenAt,
};
}
function isAgentOnline(agent) {
return Boolean(agent?.socket && agent.socket.readyState === WebSocket.OPEN);
}
function settleAll(agent, error) {
for (const pending of agent.pending.values()) {
clearTimeout(pending.timeout);
pending.reject(error);
}
agent.pending.clear();
}
function trimRequestMeta(agent) {
if (!agent?.requestMeta || agent.requestMeta.size <= MAX_REQUEST_META_PER_AGENT) return;
const excess = agent.requestMeta.size - MAX_REQUEST_META_PER_AGENT;
Array.from(agent.requestMeta.keys()).slice(0, excess).forEach((key) => agent.requestMeta.delete(key));
}
function sendJson(socket, payload) {
socket.send(JSON.stringify(payload));
}
function cleanPairingCode(value) {
return String(value || "")
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, "")
.slice(0, 32);
}
function cleanString(value, maxLength) {
return String(value || "").trim().slice(0, maxLength);
}
function sanitizeTimeoutMs(value, fallback) {
const numeric = Number(value || fallback);
if (!Number.isFinite(numeric)) return fallback;
return Math.max(1000, Math.min(12 * 60 * 60 * 1000, numeric));
}
function elapsedLabel(startedAt) {
const elapsed = Math.max(0, Date.now() - Number(startedAt || Date.now()));
if (elapsed < 1000) return `${elapsed}ms`;
return `${(elapsed / 1000).toFixed(1)}s`;
}
function asyncRoute(handler) {
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
}
function requireInternalApi(req, res, next) {
if (!config.internalAccessToken) {
res.status(503).json({ ok: false, error: "ai_workspace_hub_token_not_configured" });
return;
}
const header = String(req.headers.authorization || "");
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token || !safeEqual(token, config.internalAccessToken)) {
res.status(401).json({ ok: false, error: "ai_workspace_hub_unauthorized" });
return;
}
next();
}
function safeEqual(left, right) {
const leftBuffer = Buffer.from(String(left || ""));
const rightBuffer = Buffer.from(String(right || ""));
return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
function readConfig() {
return {
port: Number(process.env.PORT || 18081),
wsPath: process.env.AI_WORKSPACE_HUB_WS_PATH || DEFAULT_WS_PATH,
internalAccessToken: process.env.AI_WORKSPACE_HUB_TOKEN || process.env.NODEDC_INTERNAL_ACCESS_TOKEN || "",
};
}
function shutdown() {
clearInterval(heartbeat);
wss.close();
httpServer.close(() => {
process.exit(0);
});
setTimeout(() => process.exit(1), 5000).unref();
}