diff --git a/Dockerfile.standalone b/Dockerfile.standalone new file mode 100644 index 00000000..536ae323 --- /dev/null +++ b/Dockerfile.standalone @@ -0,0 +1,30 @@ +# Builds the standalone (mock data) WASM module, generating the mock navigation data first. Unlike the sim build, this needs no MSFS SDK. +FROM rust:1.90 + +ARG WASI_SDK_VERSION=25 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends wget ca-certificates git && \ + rm -rf /var/lib/apt/lists/* + +# Install wasi-sdk, used to compile SQLite's C code for wasm32-wasip1 +RUN wget -q https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz && \ + tar -xzf wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz -C /opt && \ + mv /opt/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux /opt/wasi-sdk && \ + rm wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz + +# The rusqlite fork takes the wasi sysroot from $MSFS_SDK/WASM/wasi-sysroot, so point that at the wasi-sdk sysroot +RUN mkdir -p /opt/msfs-sdk-shim/WASM && \ + ln -s /opt/wasi-sdk/share/wasi-sysroot /opt/msfs-sdk-shim/WASM/wasi-sysroot + +ENV MSFS_SDK=/opt/msfs-sdk-shim \ + CC_wasm32_wasip1=/opt/wasi-sdk/bin/clang \ + AR_wasm32_wasip1=/opt/wasi-sdk/bin/llvm-ar + +RUN rustup target add wasm32-wasip1 + +# Bun runs the mock navigation data generator (scripts/generate-mock-navdata.ts) before each build +COPY --from=oven/bun:1 /usr/local/bin/bun /usr/local/bin/bun + +# Needed when running in CI/CD to avoid dubious ownership errors +RUN git config --global --add safe.directory /workspace diff --git a/README.md b/README.md index c0c9e353..9e4bed5a 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,13 @@ - `example/` - `aircraft/` includes a base aircraft to test in the sim - `gauge/` includes a very simple TypeScript instrument to communicate with the WASM module + - `standalone-demo/` runs the [standalone module](#running-outside-the-sim-standalone-mode) outside the sim, as a script and as a NestJS HTTP API with Swagger UI +- `scripts/` includes the build scripts, including the [standalone build](#running-outside-the-sim-standalone-mode) and its mock data generator - `src/` - `ts` includes source code for the JS interface for interfacing with the WASM module + - `transport/` includes the [transports](#using-the-js-interface-with-a-transport) used to reach the WASM module (CommBus in the sim, or the standalone module) - `wasm` includes the Rust source code for the WASM module which handles the downloading of the database file, and interfacing with the database + - `platform/` includes the [platform adapters](#platforms) (MSFS or standalone), selected at compile time ## Including in Your Aircraft @@ -102,6 +106,126 @@ The default location for navigation data is `work/NavigationData`. 1. Change directory to [`example/gauge`](example/gauge/) using `cd example/gauge` 2. Run `bun run build` to build into the `PackageSources` folder of the aircraft sample (or `bun run dev` to build into the `Packages` folder of the aircraft and listen to changes in the source). +## Running Outside the Sim (Standalone Mode) + +The WASM module can also be built as a **standalone** module, which runs outside the simulator (in a browser, or in Bun/Node) and serves mock navigation data. This lets you develop and test instruments and tools against the real interface, without starting MSFS. + +The standalone module runs the same Rust core as the sim build: every function (including `ExecuteSQLQuery`) goes through the same dispatch and the same SQL queries. Only the platform-specific parts are swapped out, and the navigation data is a mock database embedded in the module. + +### Platforms + +The Rust code is split into a shared core and a platform adapter, selected at compile time with a Cargo feature: + +``` + Rust core + (database, SQL queries, function dispatch, events) + │ + trait Platform + / \ + MsfsPlatform StandalonePlatform + CommBus host import (navigraph.send_message) + work folder, embedded mock database + bundled data, (in-memory SQLite) + network download simulated download +``` + +| Feature | Build command | Used for | +| ---------------- | ------------------------------- | --------------------------------------- | +| `msfs` (default) | `bun run build:wasm` | The gauge in MSFS 2020 & 2024 | +| `standalone` | `bun run build:wasm:standalone` | Running outside the sim, with mock data | + +The two features are mutually exclusive. The platform trait lives in [`src/wasm/src/platform`](src/wasm/src/platform/mod.rs), and the core only talks to the platform through it. + +| Behaviour | `msfs` | `standalone` | +| -------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------ | +| Messages to JS | CommBus | `navigraph.send_message`, imported from the host | +| Navigation data | `work/NavigationData` (bundled or downloaded) | Mock database, embedded in the module | +| `DownloadNavigationData` | Downloads and installs the data from the URL | Sends `DownloadProgress` events, then reloads the mock data (no network) | +| `GetNavigationDataInstallStatus` | Installed cycle, and latest cycle from the Navigraph API | Cycle of the mock data | + +### Building the Standalone Module + +1. Make sure Docker is running. +2. Run `bun run build:wasm:standalone` at the root of the repository. + - The first run builds the `navigation-data-interface-standalone-build` image from [`Dockerfile.standalone`](Dockerfile.standalone). It does not need the MSFS SDK. + - The mock navigation data is generated inside the container, right before the build (see [Mock Navigation Data](#mock-navigation-data)). +3. The module is written to `dist/standalone/msfs_navigation_data_interface.wasm`. + +> [!IMPORTANT] +> The standalone module must be built through `bun run build:wasm:standalone`. Running `cargo build --no-default-features --features standalone` directly fails, as the mock navigation data only exists in the build container. + +### Mock Navigation Data + +The mock navigation data is generated by [`scripts/generate-mock-navdata.ts`](scripts/generate-mock-navdata.ts), inside the standalone build container. It is never committed, and is written to `targets/standalone/mock-data`. + +It is a subset of the [example bundled database](example/aircraft/PackageSources/Navigraph/BundledData/), so it has exactly the same schema as real navigation data: + +- The selected airports, with all their procedures, runways, gates, communications, etc. By default these are `MMUN`, `MMMD` and `MSLP`. +- All other airports (with their runways) within about 2° of the selected airports, so range queries return realistic results. +- Enroute waypoints, navaids, holdings and communications in the same area, plus the airways, airspaces and FIRs passing through it. + +To generate the mock data for other airports, pass them to the build (they must exist in the example database): + +```sh +bun run build:wasm:standalone MMMX MMGL +``` + +### Using the JS Interface with a Transport + +The JS interface talks to the WASM module through a transport, found in [`src/ts/transport`](src/ts/transport): + +- `CommBusTransport`: talks to the gauge in the sim over the CommBus. +- `StandaloneTransport`: loads the standalone module, drives it (the equivalent of sim frames), and passes calls to it. It works in browsers and in Bun/Node. + +By default, the interface uses the CommBus when it is available. To run outside the sim, pass the standalone module: + +```ts +import { NavigraphNavigationDataInterface, TransportMode } from "@navigraph/msfs-navigation-data-interface"; + +// In the sim (default): uses the CommBus +const simInterface = new NavigraphNavigationDataInterface(); + +// Outside the sim: runs the standalone module, serving mock data +const navigationDataInterface = new NavigraphNavigationDataInterface({ + mode: TransportMode.Standalone, + standalone: { + // A URL to fetch, the module bytes, a compiled WebAssembly.Module, or a fetch Response + wasm: "/msfs_navigation_data_interface.wasm", + }, +}); + +navigationDataInterface.onReady(async () => { + const airport = await navigationDataInterface.get_airport("MMUN"); +}); +``` + +For runnable examples, see [`example/standalone-demo`](example/standalone-demo): a script (`bun run demo:standalone`), and a NestJS HTTP API with Swagger UI serving the mock data (`bun run demo:standalone:serve`). + +The available modes are: + +- `TransportMode.Auto` (default): uses the CommBus when `RegisterCommBusListener` is available. Otherwise, uses the standalone module if `standalone` options are passed, and throws if they are not. +- `TransportMode.CommBus`: always uses the CommBus. +- `TransportMode.Standalone`: always uses the standalone module (requires `standalone` options). + +You can also pass your own transport, by implementing the `NavigationDataTransport` interface (`call` and `onEvent`): + +```ts +const navigationDataInterface = new NavigraphNavigationDataInterface(myTransport); +``` + +### Hosting the Standalone Module Yourself + +If you are not using the JS interface, the standalone module can be driven directly. It uses the same payloads and channels as the [CommBus events](#interfacing-with-the-gauge-manually): + +- Exports: + - `navigraph_alloc(len) -> ptr` allocates a buffer in the module memory. + - `navigraph_call_function(ptr, len)` queues a function call. The buffer holds a UTF-8 `NAVIGRAPH_CallFunction` payload, and ownership passes to the module. + - `navigraph_dealloc(ptr, len)` frees a buffer which was not passed to `navigraph_call_function`. + - `navigraph_update()` runs queued functions and sends the heartbeat. Call it regularly, like a sim frame (e.g. every 16ms). +- Imports: + - `navigraph.send_message(channel_ptr, channel_len, data_ptr, data_len)` receives `NAVIGRAPH_FunctionResult` and `NAVIGRAPH_Event` messages (UTF-8). Copy the data before returning, and avoid calling back into the module from it. + - `wasi_snapshot_preview1`, as the module targets `wasm32-wasip1`. Only stdout/stderr, clocks and randomness are needed; [`StandaloneTransport`](src/ts/transport/StandaloneTransport.ts) contains a minimal implementation. + ## Interfacing with the gauge manually The navigation data interface acts as its own WASM gauge in sim, so in order to communicate with it, you must use the [CommBus](https://docs.flightsimulator.com/html/Programming_Tools/WASM/Communication_API/Communication_API.htm). diff --git a/bun.lock b/bun.lock index 568aaf84..bf5705cb 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "msfs-navigation-data-interface", @@ -43,9 +44,21 @@ "tailwindcss": "^3.4.14", }, }, + "example/standalone-demo": { + "name": "standalone-demo", + "version": "1.0.0", + "dependencies": { + "@nestjs/common": "^12.1.0", + "@nestjs/core": "^12.1.0", + "@nestjs/platform-express": "^12.1.0", + "@nestjs/swagger": "^12.0.2", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + }, + }, "src/ts": { "name": "@navigraph/msfs-navigation-data-interface", - "version": "1.2.2", + "version": "1.2.3", "devDependencies": { "@microsoft/msfs-types": "^1.14.6", "tsup": "^8.4.0", @@ -55,6 +68,8 @@ "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], @@ -143,10 +158,14 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + "@lukeed/csprng": ["@lukeed/csprng@1.1.0", "", {}, "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA=="], + "@microsoft/msfs-sdk": ["@microsoft/msfs-sdk@0.6.0", "", {}, "sha512-fEOg+IynDit88HDKAZkgEoRIrbrTfIQioNO1Ka42PJ+HdjyYC+4neVvs8ohfJyCi7rLzIwlCe3VcsSnNIGUNig=="], "@microsoft/msfs-types": ["@microsoft/msfs-types@1.14.6", "", {}, "sha512-p2dmrxMpnurr7lOFRKjLCysxR6bb+MWJmRvYQkaExq7qBc8bu98WgI14X8W+pf2g0rlH69cN+uP9Kvz/dnPDuw=="], + "@microsoft/tsdoc": ["@microsoft/tsdoc@0.17.0", "", {}, "sha512-p68VexhnH7ojf3U27RdryUDqnKJgKaWUbCL96vJR7N0mqDuhKG5OIECk3sCPPdynxjk+yoVhRkScqtv5KQjzsw=="], + "@navigraph/app": ["@navigraph/app@1.3.5", "", {}, "sha512-TyfqLvc9AsyXFSaExj0wpo80Qrl/uPYXs1PTx3zVRUSAoGAtsqawtKLC1AFGtfz2EzF/IAeQ3+RMSS24ykFWAA=="], "@navigraph/auth": ["@navigraph/auth@2.5.1", "", { "dependencies": { "@navigraph/app": "1.3.5" } }, "sha512-wM0UAlEtEyFT7lZMnLeSjOFt8GfsaJz41JePBVVgCauoRGZYcVxmkKz9IUMQVBgGsdCn5fOillT7LfpDqsUHQg=="], @@ -157,6 +176,16 @@ "@navigraph/packages": ["@navigraph/packages@1.0.0", "", { "dependencies": { "@navigraph/app": "1.3.5", "@navigraph/auth": "2.5.1" } }, "sha512-1JHR+BakEAFnIFd5YqMRMlLIu6TWyPVGTuvls0UGk8ylihHdX49EmCj0s2qv19mratjVaEjhiy5LSsNFst3few=="], + "@nestjs/common": ["@nestjs/common@12.1.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "file-type": "22.1.1", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "class-transformer": ">=0.4.1", "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-YGNboXyCmLWwIyrrVLst2dsH+DjsIje4S3P2i06HRwV0Sdcp+qZ7UV3PvIvCD4mI/VGH/f4p0Oq2sMVkVB0GOA=="], + + "@nestjs/core": ["@nestjs/core@12.1.0", "", { "dependencies": { "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", "path-to-regexp": "8.4.2", "tslib": "2.8.1", "uid": "2.0.2" }, "peerDependencies": { "@nestjs/common": "^12.0.0", "@nestjs/microservices": "^12.0.0", "@nestjs/platform-express": "^12.0.0", "@nestjs/websockets": "^12.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, "optionalPeers": ["@nestjs/microservices", "@nestjs/platform-express", "@nestjs/websockets"] }, "sha512-lI/JwCwuDgvJPHZMqQdP7vDwQFPzWQ0Sgsqe6SeCdN3FWu2t+9kkW7kLxrPcRnTdyB9XnuCanh9J9oMc/pQgUg=="], + + "@nestjs/mapped-types": ["@nestjs/mapped-types@12.0.0", "", { "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", "class-transformer": "^0.4.0 || ^0.5.0", "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", "reflect-metadata": "^0.1.12 || ^0.2.0" }, "optionalPeers": ["class-transformer", "class-validator"] }, "sha512-aX7lxZcqXldtnSr0bgT2ZPQvpTpIwBMW1GMX29o4s3q02vmq6JoTYuPW0GwYXa8BiFMRX7WbGZVDA703pNqOMQ=="], + + "@nestjs/platform-express": ["@nestjs/platform-express@12.1.0", "", { "dependencies": { "cors": "2.8.6", "express": "5.2.1", "multer": "2.4.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, "peerDependencies": { "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0" } }, "sha512-LOyutflFXjV+ahGKpcje9x1BFSVj9GjOE7kU1kGpI3P+NBzEf3dnsES/fmGuW8R7gV11VJlRz7u+3wYbda5/xg=="], + + "@nestjs/swagger": ["@nestjs/swagger@12.0.2", "", { "dependencies": { "@microsoft/tsdoc": "0.17.0", "@nestjs/mapped-types": "12.0.0", "@standard-schema/spec": "1.1.0", "es-toolkit": "1.52.0", "js-yaml": "5.4.2", "path-to-regexp": "8.4.2", "swagger-ui-dist": "5.33.0" }, "peerDependencies": { "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", "@nestjs/common": "^12.0.0", "@nestjs/core": "^12.0.0", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0", "typescript": "^5.5.0 || ^6.0.0" }, "optionalPeers": ["@fastify/static", "class-transformer", "class-validator", "typescript"] }, "sha512-/IDj6o2k1wqZybhIkMaJp58YNrAos/4TwTf3zBn/j1flKpMb5Tyfbj9sP6rpLucQ7PzHHu7n4ucmeHWEisfn6A=="], + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], @@ -213,6 +242,14 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], "@types/bun": ["@types/bun@1.2.8", "", { "dependencies": { "bun-types": "1.2.7" } }, "sha512-t8L1RvJVUghW5V+M/fL3Thbxcs0HwNsXsnTEBEfEVqGteiJToOlZ/fyOEaR1kZsNqnu+3XA4RI/qmnX4w6+S+w=="], @@ -249,6 +286,8 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.29.0", "", { "dependencies": { "@typescript-eslint/types": "8.29.0", "eslint-visitor-keys": "^4.2.0" } }, "sha512-Sne/pVz8ryR03NFK21VpN88dZ2FdQXOlq3VIklbrTYEt8yXtRFr9tvUhqvCeKjqYk5FSim37sHbooT6vzBTZcg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -263,6 +302,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], + "archiver": ["archiver@5.3.2", "", { "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" } }, "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw=="], "archiver-utils": ["archiver-utils@2.1.0", "", { "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" } }, "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw=="], @@ -287,6 +328,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], @@ -303,8 +346,16 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], @@ -337,8 +388,18 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw=="], @@ -371,6 +432,8 @@ "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -389,22 +452,38 @@ "dotenv-safe": ["dotenv-safe@9.1.0", "", { "peerDependencies": { "dotenv": ">= 8.2.0" } }, "sha512-2qwVAnUN+EDpu41pIK1XiJpHXKHV9Dnti3cE1EnUXT1/BV5+B7xuSZtgZ/4LExkCpp5F6BGikraezQL+8hKCOA=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "electron-to-chromium": ["electron-to-chromium@1.5.132", "", {}, "sha512-QgX9EBvWGmvSRa74zqfnG7+Eno0Ak0vftBll0Pt2/z5b3bEGYL6OUXLgKPtvx73dn3dvwrlyVkjPKRRlhLYTEg=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.4", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q=="], "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + "es-module-lexer": ["es-module-lexer@1.6.0", "", {}, "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ=="], + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.24.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.0", "@eslint/core": "^0.12.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.24.0", "@eslint/plugin-kit": "^0.2.7", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ=="], @@ -429,8 +508,12 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "express": ["express@5.2.1", "", { "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" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="], @@ -441,14 +524,20 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], "fdir": ["fdir@6.4.3", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-type": ["file-type@22.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "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" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], @@ -457,8 +546,12 @@ "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], "fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], @@ -475,6 +568,10 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-tsconfig": ["get-tsconfig@4.10.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A=="], "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], @@ -485,14 +582,22 @@ "globby": ["globby@10.0.1", "", { "dependencies": { "@types/glob": "^7.1.1", "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.0.3", "glob": "^7.1.3", "ignore": "^5.1.1", "merge2": "^1.2.3", "slash": "^3.0.0" } }, "sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + "icss-replace-symbols": ["icss-replace-symbols@1.1.0", "", {}, "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg=="], "icss-utils": ["icss-utils@5.1.0", "", { "peerDependencies": { "postcss": "^8.1.0" } }, "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA=="], @@ -513,6 +618,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], @@ -529,17 +636,21 @@ "is-plain-object": ["is-plain-object@3.0.1", "", {}, "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "iterare": ["iterare@1.2.1", "", {}, "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@5.4.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -559,6 +670,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "load-esm": ["load-esm@1.0.3", "", {}, "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA=="], + "load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="], "loader-utils": ["loader-utils@3.3.1", "", {}, "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg=="], @@ -589,18 +702,30 @@ "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.0.14", "", {}, "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multer": ["multer@2.4.0", "", { "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", "type-is": "^1.6.18" } }, "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg=="], + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -609,6 +734,8 @@ "navigraph": ["navigraph@1.2.35", "", { "dependencies": { "@navigraph/app": "1.3.5", "@navigraph/auth": "2.5.1", "@navigraph/charts": "2.0.4", "@navigraph/packages": "1.0.0" } }, "sha512-ykcbI8J9mHQc0YIEZ1hNWUWg1toyHfcXi/bVnmpGeaLRWCboI4XfRS35WCHIR77QUywbUsegqy+5WQaEEmGr6g=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], @@ -623,6 +750,10 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -641,6 +772,8 @@ "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -651,6 +784,8 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -751,10 +886,18 @@ "promise.series": ["promise.series@0.2.0", "", {}, "sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -763,6 +906,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], @@ -785,18 +930,38 @@ "rollup-pluginutils": ["rollup-pluginutils@2.8.2", "", { "dependencies": { "estree-walker": "^0.6.1" } }, "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "safe-identifier": ["safe-identifier@0.4.2", "", {}, "sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "semver": ["semver@7.7.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA=="], + "send": ["send@1.2.1", "", { "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" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "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" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], @@ -807,6 +972,12 @@ "stable": ["stable@0.1.8", "", {}, "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w=="], + "standalone-demo": ["standalone-demo@workspace:example/standalone-demo"], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + "string-hash": ["string-hash@1.1.3", "", {}, "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -821,6 +992,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + "style-inject": ["style-inject@0.3.0", "", {}, "sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw=="], "stylehacks": ["stylehacks@5.1.1", "", { "dependencies": { "browserslist": "^4.21.4", "postcss-selector-parser": "^6.0.4" }, "peerDependencies": { "postcss": "^8.2.15" } }, "sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw=="], @@ -833,6 +1006,8 @@ "svgo": ["svgo@2.8.0", "", { "dependencies": { "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^4.1.3", "css-tree": "^1.1.3", "csso": "^4.2.0", "picocolors": "^1.0.0", "stable": "^0.1.8" }, "bin": { "svgo": "bin/svgo" } }, "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg=="], + "swagger-ui-dist": ["swagger-ui-dist@5.33.0", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A=="], + "synckit": ["synckit@0.11.2", "", { "dependencies": { "@pkgr/core": "^0.2.0", "tslib": "^2.8.1" } }, "sha512-1IUffI8zZ8qUMB3NUJIjk0RpLroG/8NkQDAWH1NbB2iJ0/5pn3M8rxfNzMz4GH9OnYaGYn31LEDSXJp/qIlxgA=="], "tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], @@ -849,6 +1024,10 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + "tr46": ["tr46@1.0.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -863,14 +1042,22 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript-eslint": ["typescript-eslint@8.29.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.29.0", "@typescript-eslint/parser": "8.29.0", "@typescript-eslint/utils": "8.29.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ep9rVd9B4kQsZ7ZnWCVxUE/xDLUUUsRzE0poAeNu+4CkFErLfuvPt/qtm2EpnSyfvsR0S6QzDFSrPCFBwf64fg=="], + "uid": ["uid@2.0.2", "", { "dependencies": { "@lukeed/csprng": "^1.0.0" } }, "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unplugin-utils": ["unplugin-utils@0.2.4", "", { "dependencies": { "pathe": "^2.0.2", "picomatch": "^4.0.2" } }, "sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA=="], "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], @@ -879,6 +1066,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="], "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], @@ -909,6 +1098,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@eslint/eslintrc/js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.13.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw=="], "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], @@ -919,12 +1110,18 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "@tokenizer/inflate/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "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" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "body-parser/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "concat-with-sourcemaps/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "css-tree/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -933,12 +1130,18 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "finalhandler/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "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" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "postcss-modules-local-by-default/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], "postcss-modules-scope/postcss-selector-parser": ["postcss-selector-parser@7.1.0", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA=="], @@ -951,6 +1154,10 @@ "rollup-pluginutils/estree-walker": ["estree-walker@0.6.1", "", {}, "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w=="], + "router/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "send/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], @@ -965,6 +1172,8 @@ "tsup/rollup": ["rollup@4.39.0", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.39.0", "@rollup/rollup-android-arm64": "4.39.0", "@rollup/rollup-darwin-arm64": "4.39.0", "@rollup/rollup-darwin-x64": "4.39.0", "@rollup/rollup-freebsd-arm64": "4.39.0", "@rollup/rollup-freebsd-x64": "4.39.0", "@rollup/rollup-linux-arm-gnueabihf": "4.39.0", "@rollup/rollup-linux-arm-musleabihf": "4.39.0", "@rollup/rollup-linux-arm64-gnu": "4.39.0", "@rollup/rollup-linux-arm64-musl": "4.39.0", "@rollup/rollup-linux-loongarch64-gnu": "4.39.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.39.0", "@rollup/rollup-linux-riscv64-gnu": "4.39.0", "@rollup/rollup-linux-riscv64-musl": "4.39.0", "@rollup/rollup-linux-s390x-gnu": "4.39.0", "@rollup/rollup-linux-x64-gnu": "4.39.0", "@rollup/rollup-linux-x64-musl": "4.39.0", "@rollup/rollup-win32-arm64-msvc": "4.39.0", "@rollup/rollup-win32-ia32-msvc": "4.39.0", "@rollup/rollup-win32-x64-msvc": "4.39.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "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": "^3.6.0" } }, "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw=="], "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -979,6 +1188,10 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "multer/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + + "multer/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "rollup-plugin-postcss/postcss-load-config/lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], @@ -1037,6 +1250,8 @@ "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="], + "multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "sucrase/glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], "tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/example/standalone-demo/README.md b/example/standalone-demo/README.md new file mode 100644 index 00000000..a703d82e --- /dev/null +++ b/example/standalone-demo/README.md @@ -0,0 +1,96 @@ +# Standalone Demo + +Runs the standalone build of the WASM module outside the sim, through the JS interface (`NavigraphNavigationDataInterface` with a `StandaloneTransport`). It comes in two forms: + +- A **script** (`index.ts`) which prints some mock navigation data. +- An **HTTP API** (`server.ts`, built with NestJS) which serves the mock navigation data, with an OpenAPI spec and Swagger UI. + +Both serve mock data: only the airports the mock data was generated for (`MMUN`, `MMMD` and `MSLP` by default) and their surroundings are available. See [Running Outside the Sim](../../README.md#running-outside-the-sim-standalone-mode) for how the mock data is built. + +## Prerequisites + +Build the standalone module (requires Docker) at the root of the repository: + +```sh +bun run build:wasm:standalone +``` + +This writes `dist/standalone/msfs_navigation_data_interface.wasm`, which the demo loads. The demo imports the JS interface from source (`src/ts`), so the package doesn't need to be built. + +## Script + +```sh +bun run demo:standalone # from the root, or +cd example/standalone-demo +bun start [AIRPORT] # defaults to MMUN +``` + +## HTTP API + +```sh +bun run demo:standalone:serve # from the root, or +cd example/standalone-demo +bun run serve +``` + +Then open http://localhost:3000/docs for the Swagger UI. The spec, generated from the controllers by `@nestjs/swagger`, is served at `/openapi.json`. + +| Environment variable | Default | Description | +| ---------------------- | -------------------------------------------------------- | ----------------------------- | +| `PORT` | `3000` | Port to listen on | +| `NAVIGRAPH_WASM_PATH` | `dist/standalone/msfs_navigation_data_interface.wasm` | Path of the standalone module | + +### Endpoints + +All API endpoints are under `/api`. Identifiers are case insensitive. + +| Endpoint | Description | +| ------------------------------------------ | --------------------------------------------------- | +| `GET /health` | Health check | +| `GET /api/database` | Cycle and install status of the navigation data | +| `GET /api/airports?lat=&long=&range=` | Airports within `range` NM (max 500) of a point | +| `GET /api/airports/{ident}` | An airport | +| `GET /api/airports/{ident}/runways` | Runways of an airport | +| `GET /api/airports/{ident}/departures` | Departures (SIDs) | +| `GET /api/airports/{ident}/arrivals` | Arrivals (STARs) | +| `GET /api/airports/{ident}/approaches` | Approaches | +| `GET /api/airports/{ident}/gates` | Gates | +| `GET /api/airports/{ident}/communications` | Communication frequencies | +| `GET /api/waypoints/{ident}` | Waypoints with an identifier (they're not unique) | +| `GET /api/navaids/vhf/{ident}` | VHF navaids with an identifier | +| `GET /api/navaids/ndb/{ident}` | NDB navaids with an identifier | +| `GET /api/airways/{ident}` | Airways with an identifier | + +```sh +curl http://localhost:3000/api/airports/MMUN/runways +curl "http://localhost:3000/api/airports?lat=21.04&long=-86.87&range=50" +``` + +Errors use the standard Nest format, `{ "statusCode": 404, "error": "Not Found", "message": "..." }`, with status `400` for invalid input (e.g. a malformed identifier or a missing query parameter), `404` when nothing matches, and `500` for unexpected failures of the module. + +### Structure + +The business logic is kept apart from HTTP, so each can be tested on its own: + +``` +server.ts Entry point: loads the module, starts the app +src/ + loadNavigationData.ts Loads the standalone module and waits until it's ready + NavigationDataService.ts Business logic: validation, normalization, not found checks. Plain TS, no Nest or HTTP. + errors.ts ValidationError, NotFoundError + controllers.ts All the controllers: map routes to service calls, and document them for Swagger + app.module.ts The Nest module, the filter mapping service errors to 400/404, and Swagger setup +``` + +The service depends on the `NavigationDataSource` type, the subset of the JS interface it uses, and `AppModule.register(source)` creates it. This way tests can replace the WASM module with an in-memory fake. + +## Tests + +```sh +cd example/standalone-demo +bun test +``` + +- `tests/NavigationDataService.test.ts`: the business logic, against an in-memory fake source. +- `tests/app.test.ts`: the HTTP layer (routes, status codes, OpenAPI spec and Swagger UI), against the same fake. +- `tests/integration.test.ts`: the service, and every example in the generated OpenAPI spec, against the real standalone module. Skipped when the module hasn't been built. diff --git a/example/standalone-demo/index.ts b/example/standalone-demo/index.ts new file mode 100644 index 00000000..302f15ef --- /dev/null +++ b/example/standalone-demo/index.ts @@ -0,0 +1,43 @@ +import { loadNavigationData } from "./src/loadNavigationData"; + +/// Must be one of the airports the mock data was generated for (MMUN, MMMD and MSLP by default) +const AIRPORT = (process.argv[2] ?? "MMUN").toUpperCase(); + +const { navigationDataInterface, transport } = await loadNavigationData().catch((error: unknown) => { + console.error("[-]", error instanceof Error ? error.message : error); + process.exit(1); +}); + +try { + const info = await navigationDataInterface.get_database_info(); + console.info(`Database: AIRAC ${info.airac_cycle} (${info.effective_from_to.join(" - ")})`); + + const status = await navigationDataInterface.get_navigation_data_install_status(); + console.info(`Install status: ${status.status}, path: ${status.installedPath}`); + + const airport = await navigationDataInterface.get_airport(AIRPORT); + console.info(`\n${airport.ident}: ${airport.name} (${airport.location.lat}, ${airport.location.long})`); + + const runways = await navigationDataInterface.get_runways_at_airport(AIRPORT); + console.info(`Runways: ${runways.map(r => r.ident).join(", ")}`); + + const departures = await navigationDataInterface.get_departures_at_airport(AIRPORT); + const approaches = await navigationDataInterface.get_approaches_at_airport(AIRPORT); + console.info(`Departures: ${departures.length}, approaches: ${approaches.length}`); + + const nearby = await navigationDataInterface.get_airports_in_range(airport.location, 50); + console.info(`Airports within 50 NM: ${nearby.map(a => a.ident).join(", ")}`); + + const rows = await navigationDataInterface.execute_sql<{ airport_identifier: string; airport_name: string }>( + "SELECT airport_identifier, airport_name FROM tbl_pa_airports ORDER BY airport_identifier LIMIT 5", + [], + ); + console.info("\nexecute_sql:"); + rows.forEach(row => console.info(` ${row.airport_identifier} ${row.airport_name}`)); +} catch (error) { + console.error("[-]", error instanceof Error ? error.message : error); + process.exitCode = 1; +} finally { + // The transport drives the module on an interval, which would otherwise keep the process alive + transport.dispose(); +} diff --git a/example/standalone-demo/package.json b/example/standalone-demo/package.json new file mode 100644 index 00000000..892c5a6f --- /dev/null +++ b/example/standalone-demo/package.json @@ -0,0 +1,22 @@ +{ + "name": "standalone-demo", + "version": "1.0.0", + "description": "Minimal example running the standalone WASM module outside the sim, as a script and as an HTTP API", + "private": true, + "scripts": { + "start": "bun run index.ts", + "serve": "bun run server.ts", + "test": "bun test", + "lint": "eslint -c ../../eslint.config.mjs .", + "typecheck": "tsc --noEmit" + }, + "type": "module", + "dependencies": { + "@nestjs/common": "^12.1.0", + "@nestjs/core": "^12.1.0", + "@nestjs/platform-express": "^12.1.0", + "@nestjs/swagger": "^12.0.2", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + } +} diff --git a/example/standalone-demo/server.ts b/example/standalone-demo/server.ts new file mode 100644 index 00000000..515bb817 --- /dev/null +++ b/example/standalone-demo/server.ts @@ -0,0 +1,22 @@ +import { createApp } from "./src/app.module"; +import { loadNavigationData } from "./src/loadNavigationData"; + +const PORT = Number(process.env.PORT ?? 3000); + +const { navigationDataInterface, transport } = await loadNavigationData(process.env.NAVIGRAPH_WASM_PATH).catch( + (error: unknown) => { + console.error("[-]", error instanceof Error ? error.message : error); + process.exit(1); + }, +); + +const app = await createApp(navigationDataInterface); +await app.listen(PORT); +console.info(`[+] Listening on http://localhost:${PORT} (API docs at http://localhost:${PORT}/docs)`); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.once(signal, () => { + transport.dispose(); + void app.close(); + }); +} diff --git a/example/standalone-demo/src/NavigationDataService.ts b/example/standalone-demo/src/NavigationDataService.ts new file mode 100644 index 00000000..55fa8c3d --- /dev/null +++ b/example/standalone-demo/src/NavigationDataService.ts @@ -0,0 +1,161 @@ +import type { + Airport, + Airway, + Approach, + Arrival, + Communication, + DatabaseInfo, + Departure, + Gate, + NavigationDataStatus, + NavigraphNavigationDataInterface, + NdbNavaid, + RunwayThreshold, + VhfNavaid, + Waypoint, +} from "./navigraph"; +import { NotFoundError, ValidationError } from "./errors"; + +/** The parts of the JS interface used by the service, so it can be replaced in tests */ +export type NavigationDataSource = Pick< + NavigraphNavigationDataInterface, + | "execute_sql" + | "get_database_info" + | "get_navigation_data_install_status" + | "get_airport" + | "get_airports_in_range" + | "get_runways_at_airport" + | "get_departures_at_airport" + | "get_arrivals_at_airport" + | "get_approaches_at_airport" + | "get_gates_at_airport" + | "get_communications_at_airport" + | "get_waypoints" + | "get_vhf_navaids" + | "get_ndb_navaids" + | "get_airways" +>; + +export interface DatabaseSummary { + info: DatabaseInfo; + status: NavigationDataStatus; +} + +/** The largest range accepted by range queries, to keep responses reasonably sized */ +export const MAX_RANGE_NM = 500; + +const AIRPORT_IDENT = /^[A-Z0-9]{3,4}$/; +const FIX_IDENT = /^[A-Z0-9]{1,5}$/; + +/** + * The business logic of the API: validates and normalizes input, and resolves it against the navigation data. + * Knows nothing about HTTP. + */ +export class NavigationDataService { + constructor(private readonly source: NavigationDataSource) {} + + public async getDatabase(): Promise { + const [info, status] = await Promise.all([ + this.source.get_database_info(), + this.source.get_navigation_data_install_status(), + ]); + return { info, status }; + } + + public async getAirport(ident: string): Promise { + return this.source.get_airport(await this.requireAirport(ident)); + } + + public async getAirportsInRange(lat: number, long: number, range: number): Promise { + if (!Number.isFinite(lat) || lat < -90 || lat > 90) { + throw new ValidationError("lat must be a number between -90 and 90"); + } + if (!Number.isFinite(long) || long < -180 || long > 180) { + throw new ValidationError("long must be a number between -180 and 180"); + } + if (!Number.isFinite(range) || range <= 0 || range > MAX_RANGE_NM) { + throw new ValidationError(`range must be a number greater than 0 and at most ${MAX_RANGE_NM}`); + } + + return this.source.get_airports_in_range({ lat, long }, range); + } + + public async getRunways(ident: string): Promise { + return this.source.get_runways_at_airport(await this.requireAirport(ident)); + } + + public async getDepartures(ident: string): Promise { + return this.source.get_departures_at_airport(await this.requireAirport(ident)); + } + + public async getArrivals(ident: string): Promise { + return this.source.get_arrivals_at_airport(await this.requireAirport(ident)); + } + + public async getApproaches(ident: string): Promise { + return this.source.get_approaches_at_airport(await this.requireAirport(ident)); + } + + public async getGates(ident: string): Promise { + return this.source.get_gates_at_airport(await this.requireAirport(ident)); + } + + public async getCommunications(ident: string): Promise { + return this.source.get_communications_at_airport(await this.requireAirport(ident)); + } + + public async getWaypoints(ident: string): Promise { + return this.requireFound(await this.source.get_waypoints(normalizeFixIdent(ident)), "Waypoint", ident); + } + + public async getVhfNavaids(ident: string): Promise { + return this.requireFound(await this.source.get_vhf_navaids(normalizeFixIdent(ident)), "VHF navaid", ident); + } + + public async getNdbNavaids(ident: string): Promise { + return this.requireFound(await this.source.get_ndb_navaids(normalizeFixIdent(ident)), "NDB navaid", ident); + } + + public async getAirways(ident: string): Promise { + return this.requireFound(await this.source.get_airways(normalizeFixIdent(ident)), "Airway", ident); + } + + /** + * Normalizes an airport identifier and checks that the airport exists. + * + * @remarks + * The module rejects lookups of unknown airports with a generic error, and returns empty lists for their runways, procedures, etc., + * so existence is checked explicitly to report a proper not found error. + */ + private async requireAirport(ident: string): Promise { + const normalized = ident.trim().toUpperCase(); + if (!AIRPORT_IDENT.test(normalized)) { + throw new ValidationError(`Invalid airport identifier '${ident}': expected 3-4 letters or digits`); + } + + const rows = await this.source.execute_sql<{ ident: string }>( + "SELECT airport_identifier AS ident FROM tbl_pa_airports WHERE airport_identifier = ?", + [normalized], + ); + if (rows.length === 0) { + throw new NotFoundError(`Airport '${normalized}' not found`); + } + + return normalized; + } + + private requireFound(items: T[], kind: string, ident: string): T[] { + if (items.length === 0) { + throw new NotFoundError(`${kind} '${ident.trim().toUpperCase()}' not found`); + } + return items; + } +} + +function normalizeFixIdent(ident: string): string { + const normalized = ident.trim().toUpperCase(); + if (!FIX_IDENT.test(normalized)) { + throw new ValidationError(`Invalid identifier '${ident}': expected 1-5 letters or digits`); + } + return normalized; +} diff --git a/example/standalone-demo/src/app.module.ts b/example/standalone-demo/src/app.module.ts new file mode 100644 index 00000000..32943142 --- /dev/null +++ b/example/standalone-demo/src/app.module.ts @@ -0,0 +1,69 @@ +import "reflect-metadata"; +import { + type ArgumentsHost, + BadRequestException, + Catch, + type DynamicModule, + type INestApplication, + Module, + type NestApplicationOptions, + NotFoundException, +} from "@nestjs/common"; +import { APP_FILTER, BaseExceptionFilter, NestFactory } from "@nestjs/core"; +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; +import { AirportsController, DatabaseController, EnrouteController, HealthController } from "./controllers"; +import { NotFoundError, ValidationError } from "./errors"; +import { type NavigationDataSource, NavigationDataService } from "./NavigationDataService"; + +/** Maps the service's errors to HTTP errors (400 and 404). Anything else is left to Nest, which responds 500. */ +@Catch(ValidationError, NotFoundError) +class ServiceErrorFilter extends BaseExceptionFilter { + override catch(error: ValidationError | NotFoundError, host: ArgumentsHost) { + const exception = + error instanceof ValidationError ? new BadRequestException(error.message) : new NotFoundException(error.message); + super.catch(exception, host); + } +} + +@Module({}) +export class AppModule { + /** + * @param source - The navigation data to serve: the JS interface, or a fake in tests + */ + static register(source: NavigationDataSource): DynamicModule { + return { + module: AppModule, + controllers: [HealthController, DatabaseController, AirportsController, EnrouteController], + providers: [ + // The service is plain TS, created here rather than decorated, so it stays free of the framework + { provide: NavigationDataService, useValue: new NavigationDataService(source) }, + { provide: APP_FILTER, useClass: ServiceErrorFilter }, + ], + }; + } +} + +/** + * Creates the app, with the API under `/api`, Swagger UI at `/docs` and the OpenAPI spec at `/openapi.json` + */ +export async function createApp( + source: NavigationDataSource, + options?: NestApplicationOptions, +): Promise { + const app = await NestFactory.create(AppModule.register(source), options); + app.setGlobalPrefix("api", { exclude: ["health"] }); + + const config = new DocumentBuilder() + .setTitle("Navigraph Navigation Data (standalone demo)") + .setDescription( + "Serves the mock navigation data of the standalone WASM module. Only the airports the mock data was generated for " + + "(MMUN, MMMD and MSLP by default) and their surroundings are available.", + ) + .setVersion("1.0.0") + .build(); + SwaggerModule.setup("docs", app, () => SwaggerModule.createDocument(app, config), { + jsonDocumentUrl: "openapi.json", + }); + + return app; +} diff --git a/example/standalone-demo/src/controllers.ts b/example/standalone-demo/src/controllers.ts new file mode 100644 index 00000000..85dd1160 --- /dev/null +++ b/example/standalone-demo/src/controllers.ts @@ -0,0 +1,133 @@ +import { Controller, Get, Param, ParseFloatPipe, Query } from "@nestjs/common"; +import { ApiOperation, ApiParam, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { NavigationDataService } from "./NavigationDataService"; + +/** Documents the `:ident` path parameter */ +const Ident = (example: string, description = "Identifier (case insensitive)") => + ApiParam({ name: "ident", example, description }); + +const AirportIdent = Ident("MMUN", "ICAO identifier of the airport (3-4 letters or digits, case insensitive)"); + +@ApiTags("Health") +@Controller("health") +export class HealthController { + @Get() + health() { + return { status: "ok" }; + } +} + +@ApiTags("Database") +@Controller("database") +export class DatabaseController { + constructor(private readonly service: NavigationDataService) {} + + @Get() + @ApiOperation({ summary: "Cycle and install status of the navigation data" }) + getDatabase() { + return this.service.getDatabase(); + } +} + +@ApiTags("Airports") +@Controller("airports") +export class AirportsController { + constructor(private readonly service: NavigationDataService) {} + + @Get() + @ApiOperation({ summary: "Airports within a range (NM) of a point" }) + @ApiQuery({ name: "lat", example: 21.04 }) + @ApiQuery({ name: "long", example: -86.87 }) + @ApiQuery({ name: "range", example: 50, description: "Nautical miles, at most 500" }) + getAirportsInRange( + @Query("lat", ParseFloatPipe) lat: number, + @Query("long", ParseFloatPipe) long: number, + @Query("range", ParseFloatPipe) range: number, + ) { + return this.service.getAirportsInRange(lat, long, range); + } + + @Get(":ident") + @ApiOperation({ summary: "An airport" }) + @AirportIdent + getAirport(@Param("ident") ident: string) { + return this.service.getAirport(ident); + } + + @Get(":ident/runways") + @ApiOperation({ summary: "Runways of an airport" }) + @AirportIdent + getRunways(@Param("ident") ident: string) { + return this.service.getRunways(ident); + } + + @Get(":ident/departures") + @ApiOperation({ summary: "Departures (SIDs) of an airport" }) + @AirportIdent + getDepartures(@Param("ident") ident: string) { + return this.service.getDepartures(ident); + } + + @Get(":ident/arrivals") + @ApiOperation({ summary: "Arrivals (STARs) of an airport" }) + @AirportIdent + getArrivals(@Param("ident") ident: string) { + return this.service.getArrivals(ident); + } + + @Get(":ident/approaches") + @ApiOperation({ summary: "Approaches of an airport" }) + @AirportIdent + getApproaches(@Param("ident") ident: string) { + return this.service.getApproaches(ident); + } + + @Get(":ident/gates") + @ApiOperation({ summary: "Gates of an airport" }) + @AirportIdent + getGates(@Param("ident") ident: string) { + return this.service.getGates(ident); + } + + @Get(":ident/communications") + @ApiOperation({ summary: "Communication frequencies of an airport" }) + @AirportIdent + getCommunications(@Param("ident") ident: string) { + return this.service.getCommunications(ident); + } +} + +/** Enroute lookups return every match, as identifiers are not unique */ +@ApiTags("Enroute") +@Controller() +export class EnrouteController { + constructor(private readonly service: NavigationDataService) {} + + @Get("waypoints/:ident") + @ApiOperation({ summary: "Waypoints with an identifier" }) + @Ident("COSTA") + getWaypoints(@Param("ident") ident: string) { + return this.service.getWaypoints(ident); + } + + @Get("navaids/vhf/:ident") + @ApiOperation({ summary: "VHF navaids with an identifier" }) + @Ident("CUN") + getVhfNavaids(@Param("ident") ident: string) { + return this.service.getVhfNavaids(ident); + } + + @Get("navaids/ndb/:ident") + @ApiOperation({ summary: "NDB navaids with an identifier" }) + @Ident("SGA") + getNdbNavaids(@Param("ident") ident: string) { + return this.service.getNdbNavaids(ident); + } + + @Get("airways/:ident") + @ApiOperation({ summary: "Airways with an identifier" }) + @Ident("A317") + getAirways(@Param("ident") ident: string) { + return this.service.getAirways(ident); + } +} diff --git a/example/standalone-demo/src/errors.ts b/example/standalone-demo/src/errors.ts new file mode 100644 index 00000000..a0e43c2e --- /dev/null +++ b/example/standalone-demo/src/errors.ts @@ -0,0 +1,9 @@ +/** The input is invalid (e.g. a malformed identifier or out of range coordinates) */ +export class ValidationError extends Error { + override readonly name = "ValidationError"; +} + +/** The requested item does not exist in the navigation data */ +export class NotFoundError extends Error { + override readonly name = "NotFoundError"; +} diff --git a/example/standalone-demo/src/loadNavigationData.ts b/example/standalone-demo/src/loadNavigationData.ts new file mode 100644 index 00000000..97ae6099 --- /dev/null +++ b/example/standalone-demo/src/loadNavigationData.ts @@ -0,0 +1,43 @@ +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { NavigraphNavigationDataInterface, StandaloneTransport } from "./navigraph"; + +/// The standalone module, as written by `bun run build:wasm:standalone` +export const DEFAULT_WASM_PATH = fileURLToPath( + new URL("../../../dist/standalone/msfs_navigation_data_interface.wasm", import.meta.url), +); + +export interface LoadedNavigationData { + navigationDataInterface: NavigraphNavigationDataInterface; + /** Drives the module on an interval, which keeps the process alive until it is disposed */ + transport: StandaloneTransport; +} + +/** + * Loads the standalone module and waits until it is ready (its first heartbeat) + * + * @param wasmPath - Path of the standalone module + * @param timeoutMs - Milliseconds to wait for the module to become ready + */ +export function loadNavigationData(wasmPath = DEFAULT_WASM_PATH, timeoutMs = 5000): Promise { + if (!existsSync(wasmPath)) { + return Promise.reject( + new Error(`${wasmPath} not found. Run \`bun run build:wasm:standalone\` at the root of the repository first.`), + ); + } + + const transport = new StandaloneTransport({ wasm: readFileSync(wasmPath) }); + const navigationDataInterface = new NavigraphNavigationDataInterface(transport); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + transport.dispose(); + reject(new Error(`The standalone module did not become ready within ${timeoutMs}ms`)); + }, timeoutMs); + + navigationDataInterface.onReady(() => { + clearTimeout(timeout); + resolve({ navigationDataInterface, transport }); + }); + }); +} diff --git a/example/standalone-demo/src/navigraph.ts b/example/standalone-demo/src/navigraph.ts new file mode 100644 index 00000000..2db53ec6 --- /dev/null +++ b/example/standalone-demo/src/navigraph.ts @@ -0,0 +1,4 @@ +// The JS interface, imported from source so the demo needs no build of the package +export * from "../../../src/ts"; +// Not exported by the package index +export type { NavigationDataStatus } from "../../../src/ts/types/meta"; diff --git a/example/standalone-demo/tests/NavigationDataService.test.ts b/example/standalone-demo/tests/NavigationDataService.test.ts new file mode 100644 index 00000000..666a971b --- /dev/null +++ b/example/standalone-demo/tests/NavigationDataService.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { NotFoundError, ValidationError } from "../src/errors"; +import { MAX_RANGE_NM, NavigationDataService } from "../src/NavigationDataService"; +import { expectRejection } from "./helpers"; +import { CUN_WAYPOINT, MMUN, RW12L, createFakeSource } from "./fakeSource"; + +function createService() { + const { source, calls } = createFakeSource(); + return { service: new NavigationDataService(source), calls }; +} + +describe("NavigationDataService", () => { + test("getDatabase combines the database info and install status", async () => { + const { service } = createService(); + const database = await service.getDatabase(); + expect(database.info.airac_cycle).toBe("2401"); + expect(database.status.installedPath).toBe("mock-navdata.sqlite"); + }); + + describe("airports", () => { + test("normalizes the identifier before looking up the airport", async () => { + const { service, calls } = createService(); + expect(await service.getAirport(" mmun ")).toEqual(MMUN); + expect(calls.find(c => c.method === "get_airport")?.args).toEqual(["MMUN"]); + }); + + test("rejects malformed identifiers without querying", async () => { + const { service, calls } = createService(); + for (const ident of ["", "MM", "MMUNX", "MM-N"]) { + await expectRejection(service.getAirport(ident), ValidationError); + } + expect(calls).toHaveLength(0); + }); + + test("reports unknown airports as not found", async () => { + const { service } = createService(); + await expectRejection(service.getAirport("XXXX"), NotFoundError); + await expectRejection(service.getRunways("XXXX"), NotFoundError); + }); + + test("returns the runways of a known airport", async () => { + const { service } = createService(); + expect(await service.getRunways("MMUN")).toEqual([RW12L]); + }); + }); + + describe("getAirportsInRange", () => { + test("passes valid coordinates on", async () => { + const { service, calls } = createService(); + expect(await service.getAirportsInRange(21, -86.8, 50)).toEqual([MMUN]); + expect(calls.at(-1)?.args).toEqual([{ lat: 21, long: -86.8 }, 50]); + }); + + test.each([ + [NaN, 0, 10], + [91, 0, 10], + [0, -181, 10], + [0, 0, 0], + [0, 0, MAX_RANGE_NM + 1], + ])("rejects lat=%p long=%p range=%p", async (lat, long, range) => { + const { service } = createService(); + await expectRejection(service.getAirportsInRange(lat, long, range), ValidationError); + }); + }); + + describe("enroute", () => { + test("returns all matches", async () => { + const { service } = createService(); + expect(await service.getWaypoints("cun")).toEqual([CUN_WAYPOINT]); + }); + + test("reports no match as not found", async () => { + const { service } = createService(); + await expectRejection(service.getWaypoints("NOPE"), NotFoundError); + await expectRejection(service.getAirways("UJ3"), NotFoundError); + }); + + test("rejects malformed identifiers", async () => { + const { service } = createService(); + await expectRejection(service.getVhfNavaids("TOOLONG"), ValidationError); + }); + }); +}); diff --git a/example/standalone-demo/tests/app.test.ts b/example/standalone-demo/tests/app.test.ts new file mode 100644 index 00000000..69580fa5 --- /dev/null +++ b/example/standalone-demo/tests/app.test.ts @@ -0,0 +1,91 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { INestApplication } from "@nestjs/common"; +import type { DatabaseSummary } from "../src/NavigationDataService"; +import { MMUN, createFakeSource } from "./fakeSource"; +import { type ErrorResponse, readJson, startApp } from "./helpers"; + +let app: INestApplication; +let baseUrl: string; + +beforeAll(async () => { + ({ app, baseUrl } = await startApp(createFakeSource().source)); +}); + +afterAll(async () => { + await app.close(); +}); + +const get = (path: string) => fetch(baseUrl + path); + +describe("HTTP API", () => { + test("GET /health", async () => { + const res = await get("/health"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "ok" }); + }); + + test("GET /api/database", async () => { + const res = await get("/api/database"); + expect(res.status).toBe(200); + expect((await readJson(res)).info.airac_cycle).toBe("2401"); + }); + + test("GET /api/airports/:ident", async () => { + const res = await get("/api/airports/mmun"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(MMUN); + }); + + test("GET /api/airports/:ident/runways", async () => { + const res = await get("/api/airports/MMUN/runways"); + expect(res.status).toBe(200); + expect(await res.json()).toHaveLength(1); + }); + + test("GET /api/airports with query parameters", async () => { + const res = await get("/api/airports?lat=21&long=-86.8&range=50"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([MMUN]); + }); + + test("responds 400 to invalid input", async () => { + for (const path of [ + "/api/airports/M!", + "/api/airports?lat=21&long=-86.8", + "/api/airports?lat=abc&long=0&range=5", + "/api/airports?lat=95&long=0&range=5", + ]) { + const res = await get(path); + expect({ path, status: res.status }).toEqual({ path, status: 400 }); + } + }); + + test("responds 404 when nothing matches", async () => { + const res = await get("/api/airports/XXXX/approaches"); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ statusCode: 404, error: "Not Found", message: "Airport 'XXXX' not found" }); + }); + + test("responds 500 to unexpected errors", async () => { + const { source } = createFakeSource(); + source.get_database_info = () => Promise.reject(new Error("module failure")); + const failing = await startApp(source); + try { + const res = await fetch(`${failing.baseUrl}/api/database`); + expect(res.status).toBe(500); + expect((await readJson(res)).statusCode).toBe(500); + } finally { + await failing.app.close(); + } + }); + + test("serves the OpenAPI spec and Swagger UI", async () => { + const spec = await readJson<{ openapi: string; paths: Record }>(await get("/openapi.json")); + expect(spec.openapi).toStartWith("3."); + expect(Object.keys(spec.paths)).toContain("/api/airports/{ident}/runways"); + + const docs = await get("/docs"); + expect(docs.status).toBe(200); + expect(await docs.text()).toContain("swagger-ui"); + }); +}); diff --git a/example/standalone-demo/tests/fakeSource.ts b/example/standalone-demo/tests/fakeSource.ts new file mode 100644 index 00000000..0805a433 --- /dev/null +++ b/example/standalone-demo/tests/fakeSource.ts @@ -0,0 +1,59 @@ +import type { Airport, RunwayThreshold, Waypoint } from "../src/navigraph"; +import type { NavigationDataSource } from "../src/NavigationDataService"; + +export const MMUN = { + ident: "MMUN", + icao_code: "MM", + area_code: "SAM", + name: "CANCUN INTL", + location: { lat: 21.04, long: -86.87 }, + elevation: 22, +} as Airport; + +export const RW12L = { ident: "RW12L", length: 11483 } as RunwayThreshold; + +export const CUN_WAYPOINT = { ident: "CUN", location: { lat: 21.03, long: -86.86 } } as Waypoint; + +/** + * An in-memory stand-in for the JS interface, holding one airport (MMUN) and one waypoint (CUN). + * Records the arguments of each call, so tests can check what the service passed on. + */ +export function createFakeSource() { + const calls: { method: string; args: unknown[] }[] = []; + const record = + (method: string, result: T) => + (...args: unknown[]) => { + calls.push({ method, args }); + return Promise.resolve(result); + }; + + const source: NavigationDataSource = { + execute_sql: (sql: string, params: string[]) => { + calls.push({ method: "execute_sql", args: [sql, params] }); + return Promise.resolve((params[0] === MMUN.ident ? [{ ident: MMUN.ident }] : []) as T[]); + }, + get_database_info: record("get_database_info", { + airac_cycle: "2401", + effective_from_to: ["25-01-2024", "21-02-2024"] as [string, string], + previous_from_to: ["28-12-2023", "25-01-2024"] as [string, string], + }), + get_navigation_data_install_status: record("get_navigation_data_install_status", { + status: "Manual", + installedPath: "mock-navdata.sqlite", + } as Awaited>), + get_airport: record("get_airport", MMUN), + get_airports_in_range: record("get_airports_in_range", [MMUN]), + get_runways_at_airport: record("get_runways_at_airport", [RW12L]), + get_departures_at_airport: record("get_departures_at_airport", []), + get_arrivals_at_airport: record("get_arrivals_at_airport", []), + get_approaches_at_airport: record("get_approaches_at_airport", []), + get_gates_at_airport: record("get_gates_at_airport", []), + get_communications_at_airport: record("get_communications_at_airport", []), + get_waypoints: (ident: string) => record("get_waypoints", ident === "CUN" ? [CUN_WAYPOINT] : [])(ident), + get_vhf_navaids: record("get_vhf_navaids", []), + get_ndb_navaids: record("get_ndb_navaids", []), + get_airways: record("get_airways", []), + }; + + return { source, calls }; +} diff --git a/example/standalone-demo/tests/helpers.ts b/example/standalone-demo/tests/helpers.ts new file mode 100644 index 00000000..32694b62 --- /dev/null +++ b/example/standalone-demo/tests/helpers.ts @@ -0,0 +1,34 @@ +import { expect } from "bun:test"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createApp } from "../src/app.module"; +import type { NavigationDataSource } from "../src/NavigationDataService"; + +/** Asserts that the promise rejects with an instance of the given error class */ +export async function expectRejection(promise: Promise, errorClass: new (...args: never[]) => Error) { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(errorClass); +} + +/** Starts the app with the given navigation data on a free port. Close it with `app.close()`. */ +export async function startApp(source: NavigationDataSource) { + const app = await createApp(source, { logger: false }); + await app.listen(0); + const { port } = (app.getHttpServer() as Server).address() as AddressInfo; + return { app, baseUrl: `http://localhost:${port}` }; +} + +/** Reads a JSON response body as the given type */ +export async function readJson(res: Response): Promise { + return (await res.json()) as T; +} + +/** The error body of Nest */ +export interface ErrorResponse { + statusCode: number; + message: string; + error?: string; +} diff --git a/example/standalone-demo/tests/integration.test.ts b/example/standalone-demo/tests/integration.test.ts new file mode 100644 index 00000000..2fe92f75 --- /dev/null +++ b/example/standalone-demo/tests/integration.test.ts @@ -0,0 +1,70 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { NotFoundError } from "../src/errors"; +import { DEFAULT_WASM_PATH, type LoadedNavigationData, loadNavigationData } from "../src/loadNavigationData"; +import { NavigationDataService } from "../src/NavigationDataService"; +import { expectRejection, readJson, startApp } from "./helpers"; + +interface OpenApiSpec { + paths: Record; +} + +/// Runs against the real standalone module, so it is skipped until `bun run build:wasm:standalone` has been run +describe.skipIf(!existsSync(DEFAULT_WASM_PATH))("NavigationDataService with the standalone module", () => { + let loaded: LoadedNavigationData; + let service: NavigationDataService; + + beforeAll(async () => { + loaded = await loadNavigationData(); + service = new NavigationDataService(loaded.navigationDataInterface); + }); + + afterAll(() => { + loaded?.transport.dispose(); + }); + + test("serves the mock database", async () => { + const { info, status } = await service.getDatabase(); + expect(info.airac_cycle).toMatch(/^\d{4}$/); + expect(status.installedPath).toBe("mock-navdata.sqlite"); + }); + + test("serves a mock airport with its runways and procedures", async () => { + const airport = await service.getAirport("mmun"); + expect(airport.ident).toBe("MMUN"); + expect((await service.getRunways("MMUN")).length).toBeGreaterThan(0); + expect((await service.getApproaches("MMUN")).length).toBeGreaterThan(0); + }); + + test("finds the airport by range", async () => { + const airport = await service.getAirport("MMUN"); + const nearby = await service.getAirportsInRange(airport.location.lat, airport.location.long, 10); + expect(nearby.map(a => a.ident)).toContain("MMUN"); + }); + + test("reports airports outside the mock data as not found", async () => { + await expectRejection(service.getAirport("EGLL"), NotFoundError); + }); + + test("every example in the OpenAPI spec resolves", async () => { + const { app, baseUrl } = await startApp(loaded.navigationDataInterface); + + try { + const spec = await readJson(await fetch(`${baseUrl}/openapi.json`)); + for (const [path, item] of Object.entries(spec.paths)) { + // Fill each parameter with its documented example + let url = path; + const query = new URLSearchParams(); + for (const p of item.get.parameters ?? []) { + if (p.in === "path") url = url.replace(`{${p.name}}`, String(p.schema.example)); + else query.set(p.name, String(p.schema.example)); + } + + const res = await fetch(`${baseUrl}${url}${query.size > 0 ? `?${query}` : ""}`); + expect({ path, status: res.status }).toEqual({ path, status: 200 }); + } + } finally { + await app.close(); + } + }); +}); diff --git a/example/standalone-demo/tsconfig.json b/example/standalone-demo/tsconfig.json new file mode 100644 index 00000000..929c1ac2 --- /dev/null +++ b/example/standalone-demo/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false, + "types": ["bun"] + }, + "include": ["*.ts", "src/**/*.ts", "tests/**/*.ts", "../../src/ts/CommBus.d.ts"] +} diff --git a/package.json b/package.json index 253971c0..46d61610 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,9 @@ "lint": "bun run --filter \"*\" lint", "typecheck": "bun run --filter \"*\" typecheck", "build:wasm": "bun ./scripts/cargo-msfs.ts", + "build:wasm:standalone": "bun ./scripts/cargo-standalone.ts", + "demo:standalone": "bun --cwd=\"./example/standalone-demo\" run start", + "demo:standalone:serve": "bun --cwd=\"./example/standalone-demo\" run serve", "build:gauge": "bun --cwd=\"./example/gauge\" run build", "copy:wasm": "bun run ./scripts/copy-wasm-module.ts", "build:example": "bun run build:wasm && bun run build:gauge && bun run copy:wasm", diff --git a/scripts/cargo-standalone.ts b/scripts/cargo-standalone.ts new file mode 100644 index 00000000..a9fb2c09 --- /dev/null +++ b/scripts/cargo-standalone.ts @@ -0,0 +1,61 @@ +import { $ } from "bun"; +import { copyFileSync, mkdirSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/// Builds the standalone WASM module, which runs outside the sim and serves mock navigation data. +/// +/// The mock navigation data is generated inside the container, right before the build, and embedded into the module. +/// Usage: `bun run build:wasm:standalone [AIRPORT ...]` (airports default to those in scripts/generate-mock-navdata.ts) + +/// The docker image name +const IMAGE_NAME = "navigation-data-interface-standalone-build"; + +/// Docker volume used to cache the cargo registry between builds +const REGISTRY_VOLUME = "navigation-data-interface-cargo-registry"; + +/// Where the mock navigation data is generated, relative to the workspace root (inside the gitignored targets folder) +const MOCK_DATA_DIR = "targets/standalone/mock-data"; + +const workspaceRoot = resolve(import.meta.dir, ".."); + +/// Airports to generate mock data for, passed through to the generator +const airports = process.argv.slice(2).filter(a => /^[A-Za-z0-9]{3,4}$/.test(a)); + +// Ensure docker is installed and available +await $`docker ps`.quiet().catch(() => { + console.error("[-] Docker is not installed or not running"); + process.exit(1); +}); + +// Ensure image is built +await $`docker image inspect ${IMAGE_NAME}:latest`.quiet().catch(async () => { + const dockerfilePath = resolve(workspaceRoot, "Dockerfile.standalone"); + console.info(`[*] Building '${IMAGE_NAME}' image from ${dockerfilePath}`); + await $`docker build -t ${IMAGE_NAME} -f ${dockerfilePath} ${workspaceRoot}`; +}); + +console.info("[*] Generating mock navigation data and building standalone module"); + +await $`docker run \ + --rm -t \ + --name msfs-standalone-wasm-builder \ + -v ${workspaceRoot}:/workspace \ + -v ${REGISTRY_VOLUME}:/usr/local/cargo/registry \ + -w /workspace \ + -e CARGO_TARGET_DIR=/workspace/targets/standalone \ + -e NAVIGRAPH_MOCK_DATA_DIR=/workspace/${MOCK_DATA_DIR} \ + ${IMAGE_NAME} \ + bash -c ${`bun ./scripts/generate-mock-navdata.ts --out ${MOCK_DATA_DIR} ${airports.join(" ")} && \ +cargo build -p msfs-navigation-data-interface --release --target wasm32-wasip1 --no-default-features --features standalone`}`.catch( + (err: { exitCode?: number }) => { + console.error(`[-] Error building standalone module: ${err.exitCode}`); + process.exit(1); + }, +); + +const builtModule = join(workspaceRoot, "targets/standalone/wasm32-wasip1/release/msfs_navigation_data_interface.wasm"); +const outDir = resolve(workspaceRoot, "dist/standalone"); +mkdirSync(outDir, { recursive: true }); +copyFileSync(builtModule, join(outDir, "msfs_navigation_data_interface.wasm")); + +console.info(`[+] Standalone module written to ${outDir}`); diff --git a/scripts/generate-mock-navdata.ts b/scripts/generate-mock-navdata.ts new file mode 100644 index 00000000..6e11af68 --- /dev/null +++ b/scripts/generate-mock-navdata.ts @@ -0,0 +1,160 @@ +import { Database } from "bun:sqlite"; +import { copyFileSync, existsSync, mkdirSync, rmSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +/// Generates the mock navigation data served by the standalone WASM build (`mock-navdata.sqlite` and `mock-cycle.json`). +/// +/// The data is a subset of the example bundled database, so the schema is identical to real navigation data and every +/// query of the interface works against it. +/// +/// This runs inside the standalone build container (see scripts/cargo-standalone.ts), which embeds the output into the module. +/// Usage: `bun ./scripts/generate-mock-navdata.ts --out [AIRPORT ...]` + +const workspaceRoot = resolve(import.meta.dir, ".."); +const sourceDir = join(workspaceRoot, "example/aircraft/PackageSources/Navigraph/BundledData"); +const sourceDb = join(sourceDir, "db.s3db"); + +/// Airports to include, with all their procedures, runways, gates, etc. Everything else is limited to the area around them. +const DEFAULT_AIRPORTS = ["MMUN", "MMMD", "MSLP"]; + +/// Size of the area (in degrees, in each direction) around each airport in which enroute data is kept +const AREA_MARGIN_DEGREES = 2; + +const args = process.argv.slice(2); +const outIndex = args.indexOf("--out"); +const outArg = outIndex >= 0 ? args[outIndex + 1] : undefined; +if (!outArg) { + console.error("[-] Usage: generate-mock-navdata.ts --out [AIRPORT ...]"); + process.exit(1); +} +const outDir = resolve(workspaceRoot, outArg); +const outDb = join(outDir, "mock-navdata.sqlite"); +const outCycle = join(outDir, "mock-cycle.json"); + +const airportArgs = args.filter((_, i) => i !== outIndex && i !== outIndex + 1).map(a => a.toUpperCase()); +const airports = airportArgs.length > 0 ? airportArgs : DEFAULT_AIRPORTS; + +if (!existsSync(sourceDb)) { + console.error(`[-] Source database not found at ${sourceDb}`); + process.exit(1); +} + +mkdirSync(outDir, { recursive: true }); +rmSync(outDb, { force: true }); + +const db = new Database(outDb, { create: true }); +db.run(`ATTACH DATABASE '${sourceDb.replaceAll("'", "''")}' AS src`); + +// Copy the schema (tables first, then indexes and views) +const schema = db + .query( + "SELECT type, name, sql FROM src.sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY type = 'table' DESC, name", + ) + .all() as { type: string; name: string; sql: string }[]; +for (const { sql } of schema) { + db.run(sql); +} + +// Resolve the airports and build the area around them +const airportList = airports.map(a => `'${a.replaceAll("'", "''")}'`).join(", "); +const found = db + .query( + `SELECT airport_identifier AS ident, airport_ref_latitude AS lat, airport_ref_longitude AS long FROM src.tbl_pa_airports WHERE airport_identifier IN (${airportList})`, + ) + .all() as { ident: string; lat: number; long: number }[]; + +const missing = airports.filter(a => !found.some(f => f.ident === a)); +if (missing.length > 0) { + console.error(`[-] Airports not found in the source database: ${missing.join(", ")}`); + process.exit(1); +} + +/// SQL condition matching points within the area around any of the airports +const inArea = (lat: string, long: string) => + "(" + + found + .map( + a => + `(${lat} BETWEEN ${a.lat - AREA_MARGIN_DEGREES} AND ${a.lat + AREA_MARGIN_DEGREES} AND ${long} BETWEEN ${a.long - AREA_MARGIN_DEGREES} AND ${a.long + AREA_MARGIN_DEGREES})`, + ) + .join(" OR ") + + ")"; + +/// Keeps every row of a group (e.g. all segments of an airway) when any row of the group is within the area +const wholeGroupsInArea = (table: string, key: string[], lat: string, long: string) => { + const keyExpr = key.join(" || '|' || "); + return `${keyExpr} IN (SELECT ${keyExpr} FROM src.${table} WHERE ${inArea(lat, long)})`; +}; + +const airportFilter = `airport_identifier IN (${airportList})`; + +/// All airports within the area are included (with their runways) so range queries return realistic results +const airportsInAreaFilter = `airport_identifier IN (SELECT airport_identifier FROM src.tbl_pa_airports WHERE ${inArea("airport_ref_latitude", "airport_ref_longitude")}) OR ${airportFilter}`; + +/// Row filter per table. Tables not listed (header, cruising tables, grid MORA) are copied in full. +const filters: Record = { + // Airport data + tbl_pa_airports: airportsInAreaFilter, + tbl_pg_runways: airportsInAreaFilter, + tbl_pb_gates: airportFilter, + tbl_pd_sids: airportFilter, + tbl_pe_stars: airportFilter, + tbl_pf_iaps: airportFilter, + tbl_pi_localizers_glideslopes: airportFilter, + tbl_pm_localizer_marker: airportFilter, + tbl_pn_terminal_ndbnavaids: airportFilter, + tbl_pp_pathpoint: airportFilter, + tbl_ps_airport_msa: airportFilter, + tbl_pt_gls: airportFilter, + tbl_pv_airport_communication: airportFilter, + tbl_pc_terminal_waypoints: `region_code IN (${airportList})`, + // Enroute points + tbl_d_vhfnavaids: inArea("navaid_latitude", "navaid_longitude"), + tbl_db_enroute_ndbnavaids: inArea("navaid_latitude", "navaid_longitude"), + tbl_ea_enroute_waypoints: inArea("waypoint_latitude", "waypoint_longitude"), + tbl_ep_holdings: inArea("waypoint_latitude", "waypoint_longitude"), + tbl_ev_enroute_communication: inArea("latitude", "longitude"), + // Enroute shapes, kept whole + tbl_er_enroute_airways: wholeGroupsInArea( + "tbl_er_enroute_airways", + ["route_identifier"], + "waypoint_latitude", + "waypoint_longitude", + ), + tbl_eu_enroute_airway_restriction: wholeGroupsInArea( + "tbl_er_enroute_airways", + ["route_identifier"], + "waypoint_latitude", + "waypoint_longitude", + ), + tbl_uc_controlled_airspace: wholeGroupsInArea( + "tbl_uc_controlled_airspace", + ["icao_code", "airspace_center", "multiple_code"], + "latitude", + "longitude", + ), + tbl_ur_restrictive_airspace: wholeGroupsInArea( + "tbl_ur_restrictive_airspace", + ["icao_code", "restrictive_airspace_designation", "multiple_code"], + "latitude", + "longitude", + ), + tbl_uf_fir_uir: wholeGroupsInArea("tbl_uf_fir_uir", ["fir_uir_identifier"], "fir_uir_latitude", "fir_uir_longitude"), +}; + +console.info(`[*] Generating mock navigation data for ${airports.join(", ")}`); + +for (const { name } of schema.filter(s => s.type === "table")) { + const where = filters[name]; + db.run(`INSERT INTO main.${name} SELECT * FROM src.${name}${where ? ` WHERE ${where}` : ""}`); + const { count } = db.query(`SELECT count(*) AS count FROM main.${name}`).get() as { count: number }; + console.info(` ${name.padEnd(36)} ${count}`); +} + +db.run("DETACH DATABASE src"); +db.run("VACUUM"); +db.close(); + +copyFileSync(join(sourceDir, "cycle.json"), outCycle); + +console.info(`[+] Wrote ${outDb} (${(statSync(outDb).size / 1024).toFixed(0)} KB) and ${outCycle}`); diff --git a/src/ts/index.ts b/src/ts/index.ts index d38873de..5aefb37c 100644 --- a/src/ts/index.ts +++ b/src/ts/index.ts @@ -1,3 +1,8 @@ export * from "./interface/NavigationDataInterfaceTypes"; export * from "./interface/NavigraphNavigationDataInterface"; +export * from "./transport/CommBusTransport"; +export * from "./transport/NavigationDataTransport"; +export * from "./transport/StandaloneTransport"; +export * from "./transport/WasmMessageRouter"; +export * from "./transport/createTransport"; export * from "./types"; diff --git a/src/ts/interface/NavigraphNavigationDataInterface.ts b/src/ts/interface/NavigraphNavigationDataInterface.ts index 4d9ee265..737b9019 100644 --- a/src/ts/interface/NavigraphNavigationDataInterface.ts +++ b/src/ts/interface/NavigraphNavigationDataInterface.ts @@ -19,23 +19,21 @@ import { Waypoint, } from "../types"; import { NavigationDataStatus } from "../types/meta"; +import { createTransport, CreateTransportOptions } from "../transport/createTransport"; +import { NavigationDataTransport } from "../transport/NavigationDataTransport"; import { Callback, - CommBusMessage, DownloadProgressData, - FunctionResultArgs, - FunctionResultStatus, NavigraphEventType, NavigraphFunction, RawNavigraphEvent, } from "./NavigationDataInterfaceTypes"; /** - * A TS wrapper class used for interfacing with the Navigraph Navigation Data interface WASM gauge using the CommBus + * A TS wrapper class used for interfacing with the Navigraph Navigation Data interface WASM gauge */ export class NavigraphNavigationDataInterface { - private readonly listener: CommBusListener; - private queue: CommBusMessage[] = []; + private readonly transport: NavigationDataTransport; private eventListeners: Callback[] = []; private onReadyCallback: (() => void) | null = null; @@ -45,12 +43,15 @@ export class NavigraphNavigationDataInterface { * Creates a new NavigraphNavigationDataInterface * * @remarks - * `RegisterCommBusListener` is called during construction. This means that the class must be instantiated once the function is available. + * The transport can be given either as an instance, or as options for {@link createTransport}. By default, the CommBus is used + * when `RegisterCommBusListener` is available. Outside the sim, pass `standalone` options to run the standalone WASM module, which serves mock data. + * When running in the sim, the class must be instantiated once `RegisterCommBusListener` is available. + * + * @param transport - Transport, or options to create one, used to communicate with the WASM module */ - constructor() { - this.listener = RegisterCommBusListener(() => { - this.onRegister(); - }); + constructor(transport: NavigationDataTransport | CreateTransportOptions = {}) { + this.transport = "call" in transport ? transport : createTransport(transport); + this.transport.onEvent(event => this.handleEvent(event)); } /** @@ -345,59 +346,28 @@ export class NavigraphNavigationDataInterface { throw new Error("Interface is not initialized"); } - const id = Utils.generateGUID(); - - const args = { function: name, id, data }; - - this.listener.callWasm("NAVIGRAPH_CallFunction", JSON.stringify(args)); - - return new Promise((resolve, reject) => { - this.queue.push({ - id, - resolve: (response: unknown) => resolve(response as T), - reject: (error: Error) => reject(error), - }); - }); + return await this.transport.call(name, data); } /** - * Registers the event listeners for the interface - */ - private onRegister(): void { - this.listener.on("NAVIGRAPH_FunctionResult", (jsonArgs: string) => { - const args = JSON.parse(jsonArgs) as FunctionResultArgs; - const id = args.id; - - // Find the function call in the queue and resolve/reject it - const message = this.queue.find(m => m.id === id); - if (message) { - this.queue.splice(this.queue.indexOf(message), 1); - const data = args.data; - if (args.status === FunctionResultStatus.Success) { - message.resolve(data); - } else { - message.reject(new Error(typeof data === "string" ? data : "Unknown error")); - } - } - }); - - this.listener.on("NAVIGRAPH_Event", (jsonArgs: string) => { - const args = JSON.parse(jsonArgs) as RawNavigraphEvent; - - // If this is the heartbeat event, set the interface as initialized - if (args.event === NavigraphEventType.Heartbeat && !this.isInitialized) { - this.isInitialized = true; - if (this.onReadyCallback) { - this.onReadyCallback(); - } + * Handles an event received from the transport + * + * @param event - The received event + */ + private handleEvent(event: RawNavigraphEvent): void { + // If this is the heartbeat event, set the interface as initialized + if (event.event === NavigraphEventType.Heartbeat && !this.isInitialized) { + this.isInitialized = true; + if (this.onReadyCallback) { + this.onReadyCallback(); } + } - // Call all callbacks for the event - if (args.event in NavigraphEventType) { - const callbacks = this.eventListeners.filter(cb => cb.event === args.event); - callbacks.forEach(cb => cb.callback(args.data)); - } - }); + // Call all callbacks for the event + if (event.event in NavigraphEventType) { + const callbacks = this.eventListeners.filter(cb => cb.event === event.event); + callbacks.forEach(cb => cb.callback(event.data)); + } } public onEvent(event: NavigraphEventType.Heartbeat, callback: () => void): void; diff --git a/src/ts/transport/CommBusTransport.ts b/src/ts/transport/CommBusTransport.ts new file mode 100644 index 00000000..950fe165 --- /dev/null +++ b/src/ts/transport/CommBusTransport.ts @@ -0,0 +1,44 @@ +import { NavigraphFunction, RawNavigraphEvent } from "../interface/NavigationDataInterfaceTypes"; +import { NavigationDataTransport } from "./NavigationDataTransport"; +import { WasmMessageRouter } from "./WasmMessageRouter"; + +/** + * A transport which communicates with the Navigraph Navigation Data interface WASM gauge using the CommBus + */ +export class CommBusTransport implements NavigationDataTransport { + private readonly listener: CommBusListener; + private readonly router = new WasmMessageRouter(); + + /** + * Creates a new CommBusTransport + * + * @remarks + * `RegisterCommBusListener` is called during construction. This means that the class must be instantiated once the function is available. + */ + constructor() { + this.listener = RegisterCommBusListener(() => { + this.onRegister(); + }); + } + + public call(functionName: keyof typeof NavigraphFunction, args: unknown): Promise { + const { payload, result } = this.router.createCall(Utils.generateGUID(), functionName, args); + + this.listener.callWasm("NAVIGRAPH_CallFunction", payload); + + return result; + } + + public onEvent(handler: (event: RawNavigraphEvent) => void): void { + this.router.setEventHandler(handler); + } + + /** + * Registers the CommBus event listeners + */ + private onRegister(): void { + for (const channel of ["NAVIGRAPH_FunctionResult", "NAVIGRAPH_Event"]) { + this.listener.on(channel, (jsonArgs: string) => this.router.handleMessage(channel, jsonArgs)); + } + } +} diff --git a/src/ts/transport/NavigationDataTransport.ts b/src/ts/transport/NavigationDataTransport.ts new file mode 100644 index 00000000..ec4797a4 --- /dev/null +++ b/src/ts/transport/NavigationDataTransport.ts @@ -0,0 +1,22 @@ +import { NavigraphFunction, RawNavigraphEvent } from "../interface/NavigationDataInterfaceTypes"; + +/** + * A transport used by the interface to communicate with the navigation data WASM module + */ +export interface NavigationDataTransport { + /** + * Calls a function in the WASM module + * + * @param functionName - Name of the function to call + * @param args - Data to pass to the function + * @returns A promise that resolves with the result of the function, or rejects if the function fails + */ + call(functionName: keyof typeof NavigraphFunction, args: unknown): Promise; + + /** + * Sets the handler which receives every event emitted by the WASM module + * + * @param handler - Handler to be called when an event is received + */ + onEvent(handler: (event: RawNavigraphEvent) => void): void; +} diff --git a/src/ts/transport/StandaloneTransport.ts b/src/ts/transport/StandaloneTransport.ts new file mode 100644 index 00000000..0b529f35 --- /dev/null +++ b/src/ts/transport/StandaloneTransport.ts @@ -0,0 +1,262 @@ +import { NavigraphFunction, RawNavigraphEvent } from "../interface/NavigationDataInterfaceTypes"; +import { NavigationDataTransport } from "./NavigationDataTransport"; +import { WasmMessageRouter } from "./WasmMessageRouter"; + +/** + * The standalone build of the WASM module (`bun run build:wasm:standalone`), as a URL to fetch, the module bytes, a compiled module, + * or a (pending) fetch response + */ +export type StandaloneWasmSource = string | URL | BufferSource | WebAssembly.Module | Response | PromiseLike; + +export interface StandaloneTransportOptions { + /** The standalone build of the WASM module, which serves mock data */ + wasm: StandaloneWasmSource; + /** Milliseconds between updates of the module, the equivalent of a sim frame (default: 16) */ + updateIntervalMs?: number; +} + +/** The exports of the standalone WASM module (see `src/wasm/src/standalone.rs`) */ +interface StandaloneExports { + memory: WebAssembly.Memory; + _initialize?: () => void; + navigraph_alloc(len: number): number; + navigraph_call_function(ptr: number, len: number): void; + navigraph_update(): void; +} + +/** WASI errno values */ +const ERRNO_SUCCESS = 0; +const ERRNO_BADF = 8; +const ERRNO_NOSYS = 52; + +/** WASI clock IDs */ +const CLOCK_REALTIME = 0; + +/** + * A transport which runs the standalone build of the WASM module outside the sim, serving mock data. + * + * @remarks + * Calls are passed to the module with the same payloads as the CommBus, so they are dispatched to the same functions in the module, + * which serve mock data instead of querying the navigation database. + */ +export class StandaloneTransport implements NavigationDataTransport { + private readonly router = new WasmMessageRouter(); + private readonly ready: Promise; + private exports: StandaloneExports | null = null; + private updateTimer: ReturnType | null = null; + private nextId = 0; + + /** Messages sent by the module during the current export call, delivered once it returns */ + private pendingMessages: [channel: string, data: string][] = []; + + constructor(options: StandaloneTransportOptions) { + this.ready = this.load(options.wasm).then(exports => { + this.exports = exports; + this.updateTimer = setInterval(() => this.runExport(e => e.navigraph_update()), options.updateIntervalMs ?? 16); + return exports; + }); + + this.ready.catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + console.error("[NAVIGRAPH]: Unable to load standalone module", err); + this.router.rejectAll(err); + }); + } + + public async call(functionName: keyof typeof NavigraphFunction, args: unknown): Promise { + await this.ready; + + const { payload, result } = this.router.createCall(`standalone-${this.nextId++}`, functionName, args); + + this.runExport(exports => { + const bytes = new TextEncoder().encode(payload); + const ptr = exports.navigraph_alloc(bytes.length); + new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes); + // The module takes ownership of the buffer + exports.navigraph_call_function(ptr, bytes.length); + }); + + return result; + } + + public onEvent(handler: (event: RawNavigraphEvent) => void): void { + this.router.setEventHandler(handler); + } + + /** + * Stops updating the module. Pending calls will not resolve. + */ + public dispose(): void { + if (this.updateTimer !== null) { + clearInterval(this.updateTimer); + this.updateTimer = null; + } + } + + /** + * Calls into the module, then delivers the messages it sent. + * + * @remarks + * Delivery is deferred so that handlers calling back into the transport (e.g. from an `onReady` callback) don't re-enter the module. + */ + private runExport(fn: (exports: StandaloneExports) => void): void { + if (!this.exports) { + return; + } + + fn(this.exports); + + const messages = this.pendingMessages; + this.pendingMessages = []; + messages.forEach(([channel, data]) => this.router.handleMessage(channel, data)); + } + + private async load(source: StandaloneWasmSource): Promise { + const module = await compileModule(source); + + let memory: WebAssembly.Memory | null = null; + const getMemory = () => { + if (!memory) throw new Error("Standalone module memory accessed before instantiation"); + return memory; + }; + const decoder = new TextDecoder(); + const readString = (ptr: number, len: number) => decoder.decode(new Uint8Array(getMemory().buffer, ptr, len)); + + const navigraph = { + send_message: (channelPtr: number, channelLen: number, dataPtr: number, dataLen: number) => { + this.pendingMessages.push([readString(channelPtr, channelLen), readString(dataPtr, dataLen)]); + }, + }; + + const imports = buildImports(module, { navigraph, wasi_snapshot_preview1: createWasiShim(getMemory) }); + const instance = await WebAssembly.instantiate(module, imports); + const exports = instance.exports as unknown as StandaloneExports; + + for (const name of ["memory", "navigraph_alloc", "navigraph_call_function", "navigraph_update"] as const) { + if (!(name in exports)) { + throw new Error(`The WASM module does not export '${name}'. Is it the standalone build?`); + } + } + + memory = exports.memory; + exports._initialize?.(); + + return exports; + } +} + +async function compileModule(source: StandaloneWasmSource): Promise { + if (source instanceof WebAssembly.Module) { + return source; + } + + if (typeof source === "string" || source instanceof URL || source instanceof Response || isPromiseLike(source)) { + const response = await (typeof source === "string" || source instanceof URL ? fetch(source) : source); + if (!response.ok) { + throw new Error(`Unable to fetch standalone module from ${response.url}: ${response.status}`); + } + return WebAssembly.compile(await response.arrayBuffer()); + } + + return WebAssembly.compile(source); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof (value as PromiseLike | undefined)?.then === "function"; +} + +type ImportFunctions = Record unknown>; + +/** + * Builds the import object for the module. Any WASI function which the shim doesn't implement returns `ENOSYS`. + */ +function buildImports( + module: WebAssembly.Module, + provided: { navigraph: ImportFunctions; wasi_snapshot_preview1: ImportFunctions }, +): WebAssembly.Imports { + const imports: Record = { navigraph: {}, wasi_snapshot_preview1: {} }; + + for (const { module: moduleName, name, kind } of WebAssembly.Module.imports(module)) { + if (kind !== "function" || !(moduleName === "navigraph" || moduleName === "wasi_snapshot_preview1")) { + throw new Error(`The standalone module has an unsupported import '${moduleName}.${name}'`); + } + + const fn = provided[moduleName][name]; + if (fn) { + imports[moduleName]![name] = fn; + } else if (moduleName === "wasi_snapshot_preview1") { + imports[moduleName]![name] = () => ERRNO_NOSYS; + } else { + throw new Error(`The standalone module imports '${moduleName}.${name}', which is not provided`); + } + } + + return imports as WebAssembly.Imports; +} + +/** + * A minimal WASI implementation, covering what the Rust standard library needs: stdout/stderr, clocks and randomness + */ +function createWasiShim(getMemory: () => WebAssembly.Memory): ImportFunctions { + const view = () => new DataView(getMemory().buffer); + const decoder = new TextDecoder(); + const lineBuffers: Record = { 1: "", 2: "" }; + + return { + fd_write: (fd: number, iovs: number, iovsLen: number, nwritten: number) => { + if (fd !== 1 && fd !== 2) { + return ERRNO_BADF; + } + + const dv = view(); + let written = 0; + let text = ""; + for (let i = 0; i < iovsLen; i++) { + const ptr = dv.getUint32(iovs + i * 8, true); + const len = dv.getUint32(iovs + i * 8 + 4, true); + text += decoder.decode(new Uint8Array(getMemory().buffer, ptr, len)); + written += len; + } + + // Print complete lines, keeping any partial line until the rest arrives + const lines = (lineBuffers[fd] + text).split("\n"); + lineBuffers[fd] = lines.pop() ?? ""; + lines.forEach(line => (fd === 1 ? console.info(line) : console.error(line))); + + dv.setUint32(nwritten, written, true); + return ERRNO_SUCCESS; + }, + clock_time_get: (clockId: number, _precision: unknown, time: number) => { + // Written as two 32 bit halves, as the package targets ES6 (no BigInt). Sub-microsecond precision is lost, which is fine here. + const nanos = (clockId === CLOCK_REALTIME ? Date.now() : performance.now()) * 1e6; + const high = Math.floor(nanos / 2 ** 32); + const dv = view(); + dv.setUint32(time, Math.floor(nanos - high * 2 ** 32), true); + dv.setUint32(time + 4, high, true); + return ERRNO_SUCCESS; + }, + random_get: (buf: number, len: number) => { + // getRandomValues is limited to 65536 bytes per call + for (let offset = 0; offset < len; offset += 65536) { + crypto.getRandomValues(new Uint8Array(getMemory().buffer, buf + offset, Math.min(65536, len - offset))); + } + return ERRNO_SUCCESS; + }, + environ_sizes_get: (count: number, size: number) => { + view().setUint32(count, 0, true); + view().setUint32(size, 0, true); + return ERRNO_SUCCESS; + }, + environ_get: () => ERRNO_SUCCESS, + args_sizes_get: (count: number, size: number) => { + view().setUint32(count, 0, true); + view().setUint32(size, 0, true); + return ERRNO_SUCCESS; + }, + args_get: () => ERRNO_SUCCESS, + sched_yield: () => ERRNO_SUCCESS, + proc_exit: (code: number) => { + throw new Error(`Standalone module exited with code ${code}`); + }, + }; +} diff --git a/src/ts/transport/WasmMessageRouter.ts b/src/ts/transport/WasmMessageRouter.ts new file mode 100644 index 00000000..41dfb288 --- /dev/null +++ b/src/ts/transport/WasmMessageRouter.ts @@ -0,0 +1,90 @@ +import { + CommBusMessage, + FunctionResultArgs, + FunctionResultStatus, + NavigraphFunction, + RawNavigraphEvent, +} from "../interface/NavigationDataInterfaceTypes"; + +/** + * Tracks pending function calls and routes the messages sent by the WASM module (`NAVIGRAPH_FunctionResult` and `NAVIGRAPH_Event`). + * + * @remarks + * The payloads are identical whether the module runs in the sim (CommBus) or standalone, so this is shared by all transports. + */ +export class WasmMessageRouter { + private queue: CommBusMessage[] = []; + private eventHandler: ((event: RawNavigraphEvent) => void) | null = null; + + /** + * Registers a function call, returning the `NAVIGRAPH_CallFunction` payload to send and a promise for its result + * + * @param id - Unique ID of the call, used to match it with its result + * @param functionName - Name of the function to call + * @param args - Data to pass to the function + */ + public createCall( + id: string, + functionName: keyof typeof NavigraphFunction, + args: unknown, + ): { payload: string; result: Promise } { + const result = new Promise((resolve, reject) => { + this.queue.push({ + id, + resolve: (response: unknown) => resolve(response as T), + reject: (error: Error) => reject(error), + }); + }); + + return { payload: JSON.stringify({ function: functionName, id, data: args }), result }; + } + + public setEventHandler(handler: (event: RawNavigraphEvent) => void): void { + this.eventHandler = handler; + } + + /** + * Routes a message sent by the WASM module + * + * @param channel - Channel of the message, such as `NAVIGRAPH_FunctionResult` + * @param jsonArgs - Serialized message + */ + public handleMessage(channel: string, jsonArgs: string): void { + switch (channel) { + case "NAVIGRAPH_FunctionResult": + this.handleFunctionResult(jsonArgs); + break; + case "NAVIGRAPH_Event": + this.eventHandler?.(JSON.parse(jsonArgs) as RawNavigraphEvent); + break; + } + } + + /** + * Rejects all pending calls, e.g. when the transport fails + * + * @param error - Error to reject the calls with + */ + public rejectAll(error: Error): void { + const pending = this.queue; + this.queue = []; + pending.forEach(m => m.reject(error)); + } + + private handleFunctionResult(jsonArgs: string): void { + const args = JSON.parse(jsonArgs) as FunctionResultArgs; + const id = args.id; + + // Find the function call in the queue and resolve/reject it + const message = this.queue.find(m => m.id === id); + if (message) { + this.queue.splice(this.queue.indexOf(message), 1); + const data = args.data; + if (args.status === FunctionResultStatus.Success) { + message.resolve(data); + } else { + message.reject(new Error(typeof data === "string" ? data : "Unknown error")); + } + } + } +} diff --git a/src/ts/transport/createTransport.ts b/src/ts/transport/createTransport.ts new file mode 100644 index 00000000..db698e15 --- /dev/null +++ b/src/ts/transport/createTransport.ts @@ -0,0 +1,50 @@ +import { CommBusTransport } from "./CommBusTransport"; +import { NavigationDataTransport } from "./NavigationDataTransport"; +import { StandaloneTransport, StandaloneTransportOptions } from "./StandaloneTransport"; + +export enum TransportMode { + /** Use the CommBus when it is available, otherwise fall back to the standalone module */ + Auto = "Auto", + /** Talk to the WASM gauge running in the sim, which serves the real navigation data */ + CommBus = "CommBus", + /** Run the standalone build of the WASM module outside the sim, which serves mock data */ + Standalone = "Standalone", +} + +export interface CreateTransportOptions { + /** Which transport to create (default: {@link TransportMode.Auto}) */ + mode?: TransportMode; + /** Options for the {@link StandaloneTransport}. Required when it may be created, i.e. in `Standalone` mode, or in `Auto` mode outside the sim. */ + standalone?: StandaloneTransportOptions; +} + +/** + * Returns whether the simulator CommBus API is available in the current environment + */ +export function isCommBusAvailable(): boolean { + return typeof RegisterCommBusListener === "function"; +} + +/** + * Creates a transport + * + * @param options - Which transport to create, and its options + * @returns The created transport + */ +export function createTransport(options: CreateTransportOptions = {}): NavigationDataTransport { + const mode = options.mode ?? TransportMode.Auto; + + if (mode === TransportMode.CommBus || (mode === TransportMode.Auto && isCommBusAvailable())) { + return new CommBusTransport(); + } + + if (!options.standalone) { + throw new Error( + mode === TransportMode.Auto + ? "The CommBus is not available. To run outside the sim, pass `standalone` options with the standalone WASM module." + : "The standalone transport requires `standalone` options with the standalone WASM module.", + ); + } + + return new StandaloneTransport(options.standalone); +} diff --git a/src/wasm/Cargo.toml b/src/wasm/Cargo.toml index 17c84e22..c9fed8f0 100644 --- a/src/wasm/Cargo.toml +++ b/src/wasm/Cargo.toml @@ -6,11 +6,19 @@ edition = "2021" [lib] crate-type = ["cdylib"] +[features] +default = ["msfs"] +# The MSFS gauge: CommBus, work folder navigation data and network downloads +msfs = ["dep:msfs", "dep:dotenv_codegen", "dep:uuid", "dep:zip"] +# Runs outside the sim, serving mock navigation data. Build with `bun run build:wasm:standalone`, which generates the mock data in +# the build container and runs `cargo build --no-default-features --features standalone` +standalone = ["rusqlite/serialize"] + [dependencies] anyhow = "1.0.97" -dotenv_codegen = "0.15.0" +dotenv_codegen = { version = "0.15.0", optional = true } futures-lite = "2.6.0" -msfs = { git = "https://github.com/flybywiresim/msfs-rs.git", rev = "599c75c" } +msfs = { git = "https://github.com/flybywiresim/msfs-rs.git", rev = "599c75c", optional = true } once_cell = "1.21.2" paste = "1.0.15" regex = "1.11.1" @@ -24,8 +32,10 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" serde_rusqlite = "0.36.0" serde_with = "3.12.0" -uuid = { version = "1.16.0", features = ["rng-rand", "v4"] } -zip = { version = "2.5.0", default-features = false, features = ["deflate"] } +uuid = { version = "1.16.0", features = ["rng-rand", "v4"], optional = true } +zip = { version = "2.5.0", default-features = false, features = [ + "deflate", +], optional = true } [build-dependencies] vergen-gitcl = { version = "1.0.8", features = ["build", "cargo"] } diff --git a/src/wasm/src/database/mod.rs b/src/wasm/src/database/mod.rs index 07cbb1a3..33abe865 100644 --- a/src/wasm/src/database/mod.rs +++ b/src/wasm/src/database/mod.rs @@ -5,15 +5,12 @@ use anyhow::{anyhow, Result}; use once_cell::sync::Lazy; use sentry::integrations::anyhow::capture_anyhow; use serde::Deserialize; -use std::{ - cmp::Ordering, - fs::{self, read_dir, File}, - path::{Path, PathBuf}, - sync::Mutex, -}; +use std::{fs::File, path::Path, sync::Mutex}; -use rusqlite::{params, params_from_iter, types::ValueRef, Connection, OpenFlags}; +use rusqlite::{params, params_from_iter, types::ValueRef, Connection}; use serde_json::{Number, Value}; + +use crate::platform::{ActivePlatform, Platform}; pub use utils::{Coordinates, NauticalMiles}; pub use types::{ @@ -40,50 +37,10 @@ pub use types::{ waypoint::Waypoint, }; -/// The path to the navigation data files folder in the work directory -pub const WORK_NAVIGATION_DATA_FOLDER: &str = "\\work/NavigationData"; -/// The path to the "master" cycle info JSON -pub const WORK_CYCLE_JSON_PATH: &str = "\\work/NavigationData/cycle.json"; -/// The path to the "master" SQLite DB -pub const WORK_DB_PATH: &str = "\\work/NavigationData/db.s3db"; -/// The folder name for bundled navigation data -pub const BUNDLED_FOLDER_NAME: &str = ".\\Navigraph/BundledData"; - /// The global exported database state pub static DATABASE_STATE: Lazy> = Lazy::new(|| Mutex::new(DatabaseState::new())); -/// Find the bundled navigation data distribution -fn get_bundled_db() -> Result> { - let bundled_entries = match read_dir(BUNDLED_FOLDER_NAME) { - Ok(dir) => dir.filter_map(Result::ok).collect::>(), - Err(_) => return Ok(None), - }; - - // Try finding cycle.json - let Some(cycle_file_name) = bundled_entries - .iter() - .filter_map(|e| e.file_name().to_str().map(|s| s.to_owned())) - .find(|e| *e == String::from("cycle.json")) - else { - return Ok(None); - }; - - // Try finding the DB (we don't know the full filename, only extension) - let Some(db_file_name) = bundled_entries - .iter() - .filter_map(|e| e.file_name().to_str().map(|s| s.to_owned())) - .find(|e| e.ends_with(".s3db")) - else { - return Ok(None); - }; - - Ok(Some(DatabaseDistributionInfo::new( - Path::new(&format!(".\\{BUNDLED_FOLDER_NAME}\\{cycle_file_name}")), // We need to reconstruct the bundled path to include the proper syntax to reference non-work folder files - Path::new(&format!(".\\{BUNDLED_FOLDER_NAME}\\{db_file_name}")), - )?)) -} - /// The struct representation of the cycle info JSON #[derive(Deserialize)] pub struct CycleInfo { @@ -98,37 +55,19 @@ impl CycleInfo { /// Attempt to parse from a path /// /// * `path` - The path to load from + #[cfg_attr(not(feature = "msfs"), allow(dead_code))] pub fn from_path(path: &Path) -> Result { let mut file = File::open(path)?; serde_json::from_reader(&mut file) .map_err(|e| anyhow!("error occurred reading cycle.json: {e}")) } -} - -/// A pair of a cycle info JSON and the corresponding SQLite database. -struct DatabaseDistributionInfo { - cycle_info: CycleInfo, - db_path: PathBuf, - cycle_info_path: PathBuf, -} -impl DatabaseDistributionInfo { - /// Create a new distribution info set + /// Attempt to parse from the contents of a cycle.json /// - /// * `cycle_info_path` - The path to the cycle info JSON - /// * `db_path` - The path to the SQLite DB - pub fn new(cycle_info_path: &Path, db_path: &Path) -> Result { - // Ensure paths exist (fs::exists is unreliable, so try getting a handle) - if File::open(cycle_info_path).is_err() || File::open(db_path).is_err() { - return Err(anyhow!("invalid distribution path")); - } - - Ok(Self { - cycle_info: CycleInfo::from_path(cycle_info_path)?, - db_path: db_path.to_owned(), - cycle_info_path: cycle_info_path.to_owned(), - }) + /// * `json` - The JSON to parse + pub fn from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| anyhow!("error occurred reading cycle.json: {e}")) } } @@ -145,8 +84,8 @@ impl DatabaseState { let mut instance = Self::default(); // Try to load a DB - match instance.try_load_db() { - Ok(()) => {} + match ActivePlatform::init_database() { + Ok(database) => instance.database = database, Err(e) => { capture_anyhow(&e); println!("[NAVIGRAPH]: Error trying to load DB: {e}"); @@ -156,69 +95,6 @@ impl DatabaseState { instance } - /// Try to load a database (either bundled or downloaded) - /// - /// This searches for the best DB to use by comparing the cycle and revision of both the downloaded (in work folder) and bundled navigation data. - fn try_load_db(&mut self) -> Result<()> { - // Get distribution info of both bundled and downloaded DBs, if they exist - let bundled_distribution = get_bundled_db()?; - let downloaded_distribution = - DatabaseDistributionInfo::new(Path::new(WORK_CYCLE_JSON_PATH), Path::new(WORK_DB_PATH)) - .ok(); - - // Find the most recent distribution - let latest = [downloaded_distribution, bundled_distribution] - .into_iter() - .filter_map(|d| d) - .reduce(|a, b| { - // First, compare by cycle number - match a - .cycle_info - .cycle - .parse::() - .unwrap_or(0) - .cmp(&b.cycle_info.cycle.parse::().unwrap_or(0)) - { - Ordering::Greater => a, - Ordering::Less => b, - Ordering::Equal => { - // If they are somehow equal, compare revisions - match a - .cycle_info - .revision - .parse::() - .unwrap_or(0) - .cmp(&b.cycle_info.revision.parse::().unwrap_or(0)) - { - Ordering::Greater | Ordering::Equal => a, - Ordering::Less => b, - } - } - } - }); - - // If we somehow don't have a cycle in bundled or downloaded, return an empty instance - let Some(latest) = latest else { - return Ok(()); - }; - - // Ensure parent folder exists (ignore the result as it will return an error if it already exists) - let _ = fs::create_dir_all(WORK_NAVIGATION_DATA_FOLDER); - - // Ensure files get copied over - if latest.cycle_info_path != PathBuf::from(WORK_CYCLE_JSON_PATH) { - fs::copy(&latest.cycle_info_path, WORK_CYCLE_JSON_PATH)?; - } - if latest.db_path != PathBuf::from(WORK_DB_PATH) { - fs::copy(&latest.db_path, WORK_DB_PATH)?; - } - - // The only way this can fail (since we know now that the path is valid) is if the file is corrupt, in which case we should report to sentry - self.open_connection()?; - - return Ok(()); - } - fn get_database(&self) -> Result<&Connection> { self.database.as_ref().ok_or(anyhow!("No database open")) } @@ -234,26 +110,13 @@ impl DatabaseState { } pub fn open_connection(&mut self) -> Result<()> { - // We have to open with flags because the SQLITE_OPEN_CREATE flag with the default open causes the file to - // be overwritten - let flags = OpenFlags::SQLITE_OPEN_READ_ONLY - | OpenFlags::SQLITE_OPEN_URI - | OpenFlags::SQLITE_OPEN_NO_MUTEX; - - // The WORK_DB_PATH is the "master" SQLite path. We have logic copying over bundled navigation data if needed in the DatabaseState::new function. - let conn = Connection::open_with_flags(WORK_DB_PATH, flags)?; - - // Use memory for temp storage (avoids directory issues with the work folder, with the tradeoff of higher memory usage for queries) - conn.execute_batch("PRAGMA temp_store = MEMORY")?; - - self.database = Some(conn); + self.database = Some(ActivePlatform::open_database()?); Ok(()) } pub fn get_cycle_info(&self) -> Result { - // The WORK_CYCLE_JSON_PATH is the "master" cycle JSON path. - return CycleInfo::from_path(Path::new(WORK_CYCLE_JSON_PATH)); + ActivePlatform::get_cycle_info() } pub fn execute_sql_query(&self, sql: &str, params: &Vec) -> Result { diff --git a/src/wasm/src/funcs.rs b/src/wasm/src/funcs.rs index 02d6a603..f3166ae9 100644 --- a/src/wasm/src/funcs.rs +++ b/src/wasm/src/funcs.rs @@ -1,35 +1,17 @@ -use std::{ - fs::{self, File, OpenOptions}, - io::{BufReader, Write}, -}; - use anyhow::{Context, Result}; -use msfs::network::NetworkRequestBuilder; use once_cell::sync::Lazy; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::{json, Value}; -use zip::ZipArchive; use crate::{ database::{ Airport, Airway, Approach, Arrival, Communication, ControlledAirspace, Coordinates, DatabaseInfo, Departure, Gate, GlsNavaid, NdbNavaid, PathPoint, RestrictiveAirspace, - RunwayThreshold, VhfNavaid, Waypoint, DATABASE_STATE, WORK_CYCLE_JSON_PATH, WORK_DB_PATH, - WORK_NAVIGATION_DATA_FOLDER, + RunwayThreshold, VhfNavaid, Waypoint, DATABASE_STATE, }, - futures::AsyncNetworkRequest, - DownloadProgressEvent, InterfaceEvent, + platform::{ActivePlatform, Platform}, }; -/// The URL to get the latest available cycle number -const LATEST_CYCLE_ENDPOINT: &str = "https://navdata.api.navigraph.com/info"; - -/// The path to the temporary download file -const DOWNLOAD_TEMP_FILE_PATH: &str = "\\work/ng_download.temp"; - -/// The max size in bytes of each request during the download function (set to 4MB curently) -const DOWNLOAD_CHUNK_SIZE_BYTES: usize = 4 * 1024 * 1024; - /// The trait definition for a function that can be called through the navigation data interface trait Function: DeserializeOwned { type ReturnType: Serialize; @@ -69,9 +51,9 @@ impl Function for DownloadNavigationData { type ReturnType = (); async fn run(&mut self) -> Result { - self.download_to_temp() + ActivePlatform::download_navigation_data(&self.url) .await - .context("can't download navigation data to temp file")?; + .context("can't download navigation data")?; // Only close connection if DATABASE_STATE has already been initialized - otherwise we end up unnecessarily copying the bundled data and instantly replacing it (due to initialization logic in database state) if Lazy::get(&DATABASE_STATE).is_some() { @@ -84,9 +66,9 @@ impl Function for DownloadNavigationData { .context("can't close database connection")?; } - self.extract_navigation_data() + ActivePlatform::install_downloaded_navigation_data() .await - .context("can't extract navigation data from temp file")?; + .context("can't install downloaded navigation data")?; // Open the connection DATABASE_STATE @@ -96,142 +78,10 @@ impl Function for DownloadNavigationData { .open_connection() .context("can't open database connection")?; - // Remove the temp file - fs::remove_file(DOWNLOAD_TEMP_FILE_PATH) - .context("can't remove temp download file")?; - - Ok(()) - } -} - -impl DownloadNavigationData { - /// Download the navigation data zip file to the temp file location - async fn download_to_temp(&self) -> Result<()> { - // Figure out total size of download (this request is acting like a HEAD since we don't have those in this environment. Nothing actually gets downloaded since we are constraining the range) - let request = NetworkRequestBuilder::new(&self.url) - .context("can't create new NetworkRequestBuilder")? - .with_header(&format!("Range: bytes=0-0")) - .context(".with_header() returned None")? - .get() - .context(".get() returned None")?; - - request - .wait_for_data() - .await - .context("can't wait for head request data")?; - - // Try parsing the content-range header - let total_bytes = request - .header_section("content-range") - .context("no content-range header")? - .trim() - .split("/") - .last() - .context("invalid content-range")? - .parse::() - .context("can't parse content-range total bytes")?; - - // Total amount of chunks to download. We need to download the data in chunks of DOWNLOAD_CHUNK_SIZE_BYTES to avoid a timeout, so we need to keep track of a "working" accumulation of all responses - let total_chunks = total_bytes.div_ceil(DOWNLOAD_CHUNK_SIZE_BYTES); - - // Store the download to a file to avoid holding in-memory - let mut download_file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(DOWNLOAD_TEMP_FILE_PATH) - .context("can't open temp download file")?; - - for i in 0..total_chunks { - // Calculate the range for the current chunk - let range_start = i * DOWNLOAD_CHUNK_SIZE_BYTES; - let range_end = ((i + 1) * DOWNLOAD_CHUNK_SIZE_BYTES - 1).min(total_bytes - 1); - - // Report the current download progress - InterfaceEvent::send_download_progress_event(DownloadProgressEvent { - total_bytes, - downloaded_bytes: range_start, - current_chunk: i, - total_chunks, - }) - .context("can't send download progress event")?; - - // Dispatch the request - let data = NetworkRequestBuilder::new(&self.url) - .context("can't create new NetworkRequestBuilder")? - .with_header(&format!("Range: bytes={range_start}-{range_end}")) - .context(".with_header() returned None")? - .get() - .context(".get() returned None")? - .wait_for_data() - .await - .context("can't wait for chunk request data")?; - - // Write to limit how much data we hold in memory at a time (will be a max of DOWNLOAD_CHUNK_SIZE_BYTES) - download_file - .write_all(&data) - .context("can't write chunk to temp download file")?; - } - - Ok(()) - } - - /// Extract the navigation data files from the zip file located in the temp location - async fn extract_navigation_data(&self) -> Result<()> { - // Load the zip archive - let zip_file = - File::open(DOWNLOAD_TEMP_FILE_PATH).context("can't open temp download file")?; - let mut zip = ZipArchive::new(BufReader::new(zip_file)) - .context("can't read zip archive from temp download file")?; - - // Ensure parent folder exists (ignore the result as it will return an error if it already exists) - let _ = fs::create_dir_all(WORK_NAVIGATION_DATA_FOLDER); - - // Write the cycle.json file - let mut cycle_file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(WORK_CYCLE_JSON_PATH) - .context("can't open cycle.json work path")?; - - let mut zip_cycle = zip - .by_name("cycle.json") - .context("can't find cycle.json in zip")?; - std::io::copy(&mut zip_cycle, &mut cycle_file) - .context("can't copy cycle.json from zip to work path")?; - drop(zip_cycle); - - // Write the db file - let db_name = zip - .file_names() - .find(|f| f.to_lowercase().ends_with(".s3db")) - .with_context(|| { - format!("unable to find sqlite db in zip from url {}", self.url) - })? - .to_owned(); - - let mut db_file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(WORK_DB_PATH) - .context("can't open db work path")?; - - let mut zip_db = zip.by_name(&db_name).context("can't find db in zip")?; - std::io::copy(&mut zip_db, &mut db_file) - .context("can't copy db from zip to work path")?; - Ok(()) } } -/// The return type from the latest cycle endpoint -#[derive(Deserialize)] -struct CycleResponseInfo { - cycle: String, -} - /// The return type for the `GetNavigationDataInstallStatus` function #[derive(Serialize)] struct NavigationDataInstallStatus { @@ -256,21 +106,9 @@ pub struct GetNavigationDataInstallStatus {} impl Function for GetNavigationDataInstallStatus { type ReturnType = NavigationDataInstallStatus; async fn run(&mut self) -> Result { - // Try to get the latest available cycle from our API. Support cases in which the user may be offline by returning a None instead - let latest_cycle = if let Ok(res) = NetworkRequestBuilder::new(LATEST_CYCLE_ENDPOINT) - .context("can't create new NetworkRequestBuilder")? - .get() - .context(".get() returned None")? - .wait_for_data() + let latest_cycle = ActivePlatform::get_latest_cycle() .await - { - let response_info = serde_json::from_slice::(&res) - .context("can't deserialize cycle response info")?; - - Some(response_info.cycle) - } else { - None - }; + .context("can't get latest cycle")?; match DATABASE_STATE .try_lock() @@ -284,7 +122,7 @@ impl Function for GetNavigationDataInstallStatus { installed_format: Some(cycle_info.format), installed_revision: Some(cycle_info.revision), installed_cycle: Some(cycle_info.cycle), - installed_path: Some(WORK_DB_PATH.to_owned()), + installed_path: ActivePlatform::installed_path(), validity_period: Some(cycle_info.validity_period), latest_cycle, }) @@ -581,7 +419,7 @@ make_function!( /// and we may have some functions that aren't able to resolve in a single frame. /// /// Once the future resolves, the result is automatically serialized into a `FunctionResult` structure and -/// sent across the commbus using the `NAVIGRAPH_FunctionResult` event. +/// sent to the JS side (via the active platform) using the `NAVIGRAPH_FunctionResult` event. /// /// # Note /// @@ -652,11 +490,7 @@ macro_rules! define_interface_functions { .context("can't serialize function success data")?), }) .context("can't serialize success FunctionResult")?; - msfs::commbus::CommBus::call( - "NAVIGRAPH_FunctionResult", - &serialized, - msfs::commbus::CommBusBroadcastFlags::All, - ); + ActivePlatform::send_message("NAVIGRAPH_FunctionResult", &serialized); Ok(RunStatus::Finished) } Err(err) => { @@ -668,11 +502,7 @@ macro_rules! define_interface_functions { .context("can't serialize function error string")?), }) .context("can't serialize error FunctionResult")?; - msfs::commbus::CommBus::call( - "NAVIGRAPH_FunctionResult", - &serialized, - msfs::commbus::CommBusBroadcastFlags::All, - ); + ActivePlatform::send_message("NAVIGRAPH_FunctionResult", &serialized); Err(err) } } diff --git a/src/wasm/src/interface.rs b/src/wasm/src/interface.rs new file mode 100644 index 00000000..35538655 --- /dev/null +++ b/src/wasm/src/interface.rs @@ -0,0 +1,147 @@ +use std::{collections::VecDeque, time::Instant}; + +use anyhow::Result; +use sentry::{integrations::anyhow::capture_anyhow, protocol::Context}; +use serde::Serialize; + +use crate::{ + funcs::{InterfaceFunction, RunStatus}, + platform::{ActivePlatform, Platform}, +}; + +/// Amount of MS between dispatches of the heartbeat event +const HEARTBEAT_INTERVAL_MS: u128 = 1000; + +/// The data associated with the `DownloadProgress` event +#[derive(Serialize)] +pub struct DownloadProgressEvent { + /// The total amount of bytes to download + pub total_bytes: usize, + /// The amount of bytes downloaded + pub downloaded_bytes: usize, + /// The chunk number (starting at 0) of the current download + pub current_chunk: usize, + /// The total number of chunks needed to download + pub total_chunks: usize, +} + +/// The types of events that can be emitted from the interface +#[derive(Serialize)] +enum NavigraphEventType { + Heartbeat, + DownloadProgress, +} + +/// The structure of an event message +#[derive(Serialize)] +pub struct InterfaceEvent { + event: NavigraphEventType, + data: Option, +} + +impl InterfaceEvent { + /// Send a heartbeat event + pub fn send_heartbeat() -> Result<()> { + let event = Self { + event: NavigraphEventType::Heartbeat, + data: None, + }; + + let serialized = serde_json::to_string(&event)?; + + ActivePlatform::send_message("NAVIGRAPH_Event", &serialized); + + Ok(()) + } + + /// Send a download progress event + /// + /// * `event` - The download progress event data + pub fn send_download_progress_event(event: DownloadProgressEvent) -> Result<()> { + let event = Self { + event: NavigraphEventType::DownloadProgress, + data: Some(serde_json::to_value(event)?), + }; + + let serialized = serde_json::to_string(&event)?; + + ActivePlatform::send_message("NAVIGRAPH_Event", &serialized); + + Ok(()) + } +} + +/// The queue of called functions, which are run in order, plus the heartbeat +#[derive(Default)] +pub struct FunctionQueue { + queue: VecDeque, + last_heartbeat: Option, +} + +impl FunctionQueue { + /// Queue a function call + /// + /// * `args` - The `NAVIGRAPH_CallFunction` payload + pub fn push(&mut self, args: &str) { + // Parse the message as a function. We need to trim off the null terminator at the end + let params = match serde_json::from_str::( + args.trim_end_matches(char::from(0)), + ) { + Ok(p) => p, + Err(e) => { + sentry::capture_message( + &format!("Unable to parse InterfaceFunction from {args} due to error {e}",), + sentry::Level::Warning, + ); + return; + } + }; + + // Finally, push the function into our queue + self.queue.push_back(params); + } + + /// Run the queued functions, and send the heartbeat if due + pub fn update(&mut self) -> Result<()> { + // Process one function at a time. If the function returns InProgress, don't continue on to the next item in order to preserve call order + while let Some(function) = self.queue.front_mut() { + match function.run() { + Ok(RunStatus::InProgress) => break, + Ok(RunStatus::Finished) => { + self.queue.pop_front(); + } + Err(e) => { + // Report error + sentry::with_scope( + |scope| { + scope.set_context( + "Interface Function", + Context::Other(function.get_function_details()), + ); + }, + || capture_anyhow(&e), + ); + println!("[NAVIGRAPH]: Error occurred in function execution: {e}"); + // Remove item + self.queue.pop_front(); + } + }; + } + + self.heartbeat() + } + + /// Send a heartbeat if none has been sent yet, or if we have passed the interval + pub fn heartbeat(&mut self) -> Result<()> { + let due = self + .last_heartbeat + .map_or(true, |last| last.elapsed().as_millis() >= HEARTBEAT_INTERVAL_MS); + + if due { + InterfaceEvent::send_heartbeat()?; + self.last_heartbeat = Some(Instant::now()); + } + + Ok(()) + } +} diff --git a/src/wasm/src/lib.rs b/src/wasm/src/lib.rs index 09944421..7a412412 100644 --- a/src/wasm/src/lib.rs +++ b/src/wasm/src/lib.rs @@ -1,182 +1,13 @@ -use std::{cell::RefCell, collections::VecDeque, rc::Rc, time::Instant}; +#[cfg(all(feature = "msfs", feature = "standalone"))] +compile_error!("The `msfs` and `standalone` features are mutually exclusive. Build the standalone module with `--no-default-features --features standalone`"); +#[cfg(not(any(feature = "msfs", feature = "standalone")))] +compile_error!("Either the `msfs` or the `standalone` feature must be enabled"); -use anyhow::{anyhow, Result}; -use funcs::{InterfaceFunction, RunStatus}; -use msfs::commbus::{CommBus, CommBusBroadcastFlags}; -use sentry::{integrations::anyhow::capture_anyhow, protocol::Context}; -use sentry_gauge::{wrap_gauge_with_sentry, SentryGauge}; -use serde::Serialize; - -/// Developer-configured values for interface -mod config; /// SQLite mapping implementation mod database; /// Interface function definitions mod funcs; -/// Futures implementations for use in interface functions -mod futures; -/// The sentry wrapper implementation around the MSFS gauge callbacks -mod sentry_gauge; - -/// Amount of MS between dispatches of the heartbeat commbus event -const HEARTBEAT_INTERVAL_MS: u128 = 1000; - -/// The data associated with the `DownloadProgress` event -#[derive(Serialize)] -pub struct DownloadProgressEvent { - /// The total amount of bytes to download - pub total_bytes: usize, - /// The amount of bytes downloaded - pub downloaded_bytes: usize, - /// The chunk number (starting at 0) of the current download - pub current_chunk: usize, - /// The total number of chunks needed to download - pub total_chunks: usize, -} - -/// The types of events that can be emitted from the interface -#[derive(Serialize)] -enum NavigraphEventType { - Heartbeat, - DownloadProgress, -} - -/// The structure of an event message -#[derive(Serialize)] -struct InterfaceEvent { - event: NavigraphEventType, - data: Option, -} - -impl InterfaceEvent { - /// Send a heartbeat event across the commbus - pub fn send_heartbeat() -> Result<()> { - let event = Self { - event: NavigraphEventType::Heartbeat, - data: None, - }; - - let serialized = serde_json::to_string(&event)?; - - CommBus::call("NAVIGRAPH_Event", &serialized, CommBusBroadcastFlags::All); - - Ok(()) - } - - /// Send a download progress event across the commbus - /// - /// * `event` - The download progress event data - pub fn send_download_progress_event(event: DownloadProgressEvent) -> Result<()> { - let event = Self { - event: NavigraphEventType::DownloadProgress, - data: Some(serde_json::to_value(event)?), - }; - - let serialized = serde_json::to_string(&event)?; - - CommBus::call("NAVIGRAPH_Event", &serialized, CommBusBroadcastFlags::All); - - Ok(()) - } -} - -/// The main state for the interface -struct NavigationDataInterface<'a> { - _commbus: CommBus<'a>, - processing_queue: Rc>>, - last_heartbeat: Instant, -} - -impl SentryGauge for NavigationDataInterface<'_> { - fn initialize() -> Result - where - Self: Sized, - { - // Initialize commbus - let mut commbus = CommBus::default(); - let processing_queue = Rc::new(RefCell::new(VecDeque::new())); - - // Create the NAVIGRAPH_CallFunction callback - let processing_queue_clone = Rc::clone(&processing_queue); - commbus - .register("NAVIGRAPH_CallFunction", move |args| { - // Try to get the queue - let Ok(mut processing_queue) = processing_queue_clone.try_borrow_mut() else { - sentry::capture_message( - "Unable to borrow processing queue", - sentry::Level::Warning, - ); - return; - }; - - // Parse the message as a function. We need to trim off the null terminator at the end - let params = match serde_json::from_str::( - args.trim_end_matches(char::from(0)), - ) { - Ok(p) => p, - Err(e) => { - sentry::capture_message( - &format!( - "Unable to parse InterfaceFunction from {args} due to error {e}", - ), - sentry::Level::Warning, - ); - return; - } - }; - - // Finally, push the function into our queue - processing_queue.push_back(params); - }) - .ok_or(anyhow!("Unable to register NAVIGRAPH_CallFunction"))?; - - // Send first heartbeat - let last_heartbeat = Instant::now(); - InterfaceEvent::send_heartbeat()?; - - Ok(Self { - _commbus: commbus, - processing_queue, - last_heartbeat, - }) - } - - fn update(&mut self) -> Result<()> { - let mut queue = self.processing_queue.try_borrow_mut()?; - - // Process one function at a time. If the function returns InProgress, don't continue on to the next item in order to preserve call order - while let Some(function) = queue.front_mut() { - match function.run() { - Ok(RunStatus::InProgress) => break, - Ok(RunStatus::Finished) => { - queue.pop_front(); - } - Err(e) => { - // Report error - sentry::with_scope( - |scope| { - scope.set_context( - "Interface Function", - Context::Other(function.get_function_details()), - ); - }, - || capture_anyhow(&e), - ); - println!("[NAVIGRAPH]: Error occurred in function execution: {e}"); - // Remove item - queue.pop_front(); - } - }; - } - - // Send heartbeat if we have passed the interval - if self.last_heartbeat.elapsed().as_millis() >= HEARTBEAT_INTERVAL_MS { - InterfaceEvent::send_heartbeat()?; - self.last_heartbeat = Instant::now(); - } - - Ok(()) - } -} - -sentry_gauge!(NavigationDataInterface, navigation_data_interface); +/// Function queue and events shared by all platforms +mod interface; +/// The platform adapters (MSFS gauge or standalone), selected at compile time +mod platform; diff --git a/src/wasm/src/platform/mod.rs b/src/wasm/src/platform/mod.rs new file mode 100644 index 00000000..fc27291a --- /dev/null +++ b/src/wasm/src/platform/mod.rs @@ -0,0 +1,53 @@ +//! The platform adapter layer. +//! +//! The core (database, queries, function dispatch) only talks to the platform through the [`Platform`] trait. The implementation is +//! selected at compile time with the `msfs` or `standalone` feature, and used through the [`ActivePlatform`] alias (static dispatch). + +use anyhow::Result; +use rusqlite::Connection; + +use crate::database::CycleInfo; + +/// The MSFS gauge: CommBus, work folder, bundled data and network downloads +#[cfg(feature = "msfs")] +mod msfs; +/// Runs outside the sim, serving mock navigation data generated in the standalone build container +#[cfg(feature = "standalone")] +mod standalone; + +#[cfg(feature = "msfs")] +pub(crate) type ActivePlatform = msfs::MsfsPlatform; +#[cfg(feature = "standalone")] +pub(crate) type ActivePlatform = standalone::StandalonePlatform; + +/// Everything the core needs from the environment it runs in +pub(crate) trait Platform { + /// Send a message to the JS side + /// + /// * `channel` - The channel to send the message on, such as `NAVIGRAPH_Event` + /// * `data` - The serialized message + fn send_message(channel: &str, data: &str); + + /// Prepare the navigation data to use at startup and open it. Returns `None` if there is no navigation data available. + fn init_database() -> Result>; + + /// Open the active navigation data, e.g. after a download + fn open_database() -> Result; + + /// Get the cycle info of the active navigation data + fn get_cycle_info() -> Result; + + /// Get the path of the active navigation data, as reported to the JS side + fn installed_path() -> Option; + + /// Get the latest available cycle, or `None` if it can't be determined (e.g. when offline) + async fn get_latest_cycle() -> Result>; + + /// Download navigation data from the given URL, sending `DownloadProgress` events. Must not affect the active navigation data. + /// + /// * `url` - A signed URL to download the navigation data from + async fn download_navigation_data(url: &str) -> Result<()>; + + /// Replace the active navigation data with the downloaded data. Called while the database connection is closed. + async fn install_downloaded_navigation_data() -> Result<()>; +} diff --git a/src/wasm/src/config.rs b/src/wasm/src/platform/msfs/config.rs similarity index 95% rename from src/wasm/src/config.rs rename to src/wasm/src/platform/msfs/config.rs index 33e8c0c8..8cdf6967 100644 --- a/src/wasm/src/config.rs +++ b/src/wasm/src/platform/msfs/config.rs @@ -1,30 +1,30 @@ -use std::fs::File; - -use serde::Deserialize; - -/// The path to an optional addon-specific config file containing data about the addon -const ADDON_CONFIG_FILE: &str = ".\\Navigraph/config.json"; - -/// Information about the current addon -#[derive(Deserialize)] -pub struct Addon { - pub developer: String, - pub product: String, -} - -/// Configuration data provided by the developer -#[derive(Deserialize)] -pub struct Config { - pub addon: Addon, -} - -impl Config { - /// Try to get the config - pub fn get_config() -> Option { - if let Ok(file) = File::open(ADDON_CONFIG_FILE) { - serde_json::from_reader(file).ok() - } else { - None - } - } -} +use std::fs::File; + +use serde::Deserialize; + +/// The path to an optional addon-specific config file containing data about the addon +const ADDON_CONFIG_FILE: &str = ".\\Navigraph/config.json"; + +/// Information about the current addon +#[derive(Deserialize)] +pub struct Addon { + pub developer: String, + pub product: String, +} + +/// Configuration data provided by the developer +#[derive(Deserialize)] +pub struct Config { + pub addon: Addon, +} + +impl Config { + /// Try to get the config + pub fn get_config() -> Option { + if let Ok(file) = File::open(ADDON_CONFIG_FILE) { + serde_json::from_reader(file).ok() + } else { + None + } + } +} diff --git a/src/wasm/src/futures.rs b/src/wasm/src/platform/msfs/futures.rs similarity index 100% rename from src/wasm/src/futures.rs rename to src/wasm/src/platform/msfs/futures.rs diff --git a/src/wasm/src/platform/msfs/gauge.rs b/src/wasm/src/platform/msfs/gauge.rs new file mode 100644 index 00000000..5574f0a5 --- /dev/null +++ b/src/wasm/src/platform/msfs/gauge.rs @@ -0,0 +1,55 @@ +use std::{cell::RefCell, rc::Rc}; + +use anyhow::{anyhow, Result}; +use msfs::commbus::CommBus; + +use super::sentry_gauge::{wrap_gauge_with_sentry, SentryGauge}; +use crate::interface::FunctionQueue; + +/// The main state for the interface +struct NavigationDataInterface<'a> { + _commbus: CommBus<'a>, + function_queue: Rc>, +} + +impl SentryGauge for NavigationDataInterface<'_> { + fn initialize() -> Result + where + Self: Sized, + { + // Initialize commbus + let mut commbus = CommBus::default(); + let function_queue = Rc::new(RefCell::new(FunctionQueue::default())); + + // Create the NAVIGRAPH_CallFunction callback + let function_queue_clone = Rc::clone(&function_queue); + commbus + .register("NAVIGRAPH_CallFunction", move |args| { + // Try to get the queue + let Ok(mut function_queue) = function_queue_clone.try_borrow_mut() else { + sentry::capture_message( + "Unable to borrow processing queue", + sentry::Level::Warning, + ); + return; + }; + + function_queue.push(args); + }) + .ok_or(anyhow!("Unable to register NAVIGRAPH_CallFunction"))?; + + // Send first heartbeat + function_queue.try_borrow_mut()?.heartbeat()?; + + Ok(Self { + _commbus: commbus, + function_queue, + }) + } + + fn update(&mut self) -> Result<()> { + self.function_queue.try_borrow_mut()?.update() + } +} + +crate::sentry_gauge!(NavigationDataInterface, navigation_data_interface); diff --git a/src/wasm/src/platform/msfs/mod.rs b/src/wasm/src/platform/msfs/mod.rs new file mode 100644 index 00000000..43971108 --- /dev/null +++ b/src/wasm/src/platform/msfs/mod.rs @@ -0,0 +1,350 @@ +use std::{ + cmp::Ordering, + fs::{self, read_dir, File, OpenOptions}, + io::{BufReader, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::{anyhow, Context, Result}; +use ::msfs::{ + commbus::{CommBus, CommBusBroadcastFlags}, + network::NetworkRequestBuilder, +}; +use rusqlite::{Connection, OpenFlags}; +use serde::Deserialize; +use zip::ZipArchive; + +use self::futures::AsyncNetworkRequest; +use super::Platform; +use crate::{ + database::CycleInfo, + interface::{DownloadProgressEvent, InterfaceEvent}, +}; + +/// Developer-configured values for interface +mod config; +/// Futures implementations for use in interface functions +mod futures; +/// The MSFS gauge entrypoint +mod gauge; +/// The sentry wrapper implementation around the MSFS gauge callbacks +mod sentry_gauge; + +/// The path to the navigation data files folder in the work directory +const WORK_NAVIGATION_DATA_FOLDER: &str = "\\work/NavigationData"; +/// The path to the "master" cycle info JSON +const WORK_CYCLE_JSON_PATH: &str = "\\work/NavigationData/cycle.json"; +/// The path to the "master" SQLite DB +const WORK_DB_PATH: &str = "\\work/NavigationData/db.s3db"; +/// The folder name for bundled navigation data +const BUNDLED_FOLDER_NAME: &str = ".\\Navigraph/BundledData"; + +/// The URL to get the latest available cycle number +const LATEST_CYCLE_ENDPOINT: &str = "https://navdata.api.navigraph.com/info"; + +/// The path to the temporary download file +const DOWNLOAD_TEMP_FILE_PATH: &str = "\\work/ng_download.temp"; + +/// The max size in bytes of each request during the download function (set to 4MB curently) +const DOWNLOAD_CHUNK_SIZE_BYTES: usize = 4 * 1024 * 1024; + +/// The return type from the latest cycle endpoint +#[derive(Deserialize)] +struct CycleResponseInfo { + cycle: String, +} + +/// Find the bundled navigation data distribution +fn get_bundled_db() -> Result> { + let bundled_entries = match read_dir(BUNDLED_FOLDER_NAME) { + Ok(dir) => dir.filter_map(Result::ok).collect::>(), + Err(_) => return Ok(None), + }; + + // Try finding cycle.json + let Some(cycle_file_name) = bundled_entries + .iter() + .filter_map(|e| e.file_name().to_str().map(|s| s.to_owned())) + .find(|e| *e == String::from("cycle.json")) + else { + return Ok(None); + }; + + // Try finding the DB (we don't know the full filename, only extension) + let Some(db_file_name) = bundled_entries + .iter() + .filter_map(|e| e.file_name().to_str().map(|s| s.to_owned())) + .find(|e| e.ends_with(".s3db")) + else { + return Ok(None); + }; + + Ok(Some(DatabaseDistributionInfo::new( + Path::new(&format!(".\\{BUNDLED_FOLDER_NAME}\\{cycle_file_name}")), // We need to reconstruct the bundled path to include the proper syntax to reference non-work folder files + Path::new(&format!(".\\{BUNDLED_FOLDER_NAME}\\{db_file_name}")), + )?)) +} + +/// A pair of a cycle info JSON and the corresponding SQLite database. +struct DatabaseDistributionInfo { + cycle_info: CycleInfo, + db_path: PathBuf, + cycle_info_path: PathBuf, +} + +impl DatabaseDistributionInfo { + /// Create a new distribution info set + /// + /// * `cycle_info_path` - The path to the cycle info JSON + /// * `db_path` - The path to the SQLite DB + pub fn new(cycle_info_path: &Path, db_path: &Path) -> Result { + // Ensure paths exist (fs::exists is unreliable, so try getting a handle) + if File::open(cycle_info_path).is_err() || File::open(db_path).is_err() { + return Err(anyhow!("invalid distribution path")); + } + + Ok(Self { + cycle_info: CycleInfo::from_path(cycle_info_path)?, + db_path: db_path.to_owned(), + cycle_info_path: cycle_info_path.to_owned(), + }) + } +} + +/// The MSFS platform: CommBus messaging, navigation data in the work folder (bundled or downloaded), and network downloads +pub(crate) struct MsfsPlatform; + +impl Platform for MsfsPlatform { + fn send_message(channel: &str, data: &str) { + CommBus::call(channel, data, CommBusBroadcastFlags::All); + } + + /// Try to load a database (either bundled or downloaded) + /// + /// This searches for the best DB to use by comparing the cycle and revision of both the downloaded (in work folder) and bundled navigation data. + fn init_database() -> Result> { + // Get distribution info of both bundled and downloaded DBs, if they exist + let bundled_distribution = get_bundled_db()?; + let downloaded_distribution = + DatabaseDistributionInfo::new(Path::new(WORK_CYCLE_JSON_PATH), Path::new(WORK_DB_PATH)) + .ok(); + + // Find the most recent distribution + let latest = [downloaded_distribution, bundled_distribution] + .into_iter() + .filter_map(|d| d) + .reduce(|a, b| { + // First, compare by cycle number + match a + .cycle_info + .cycle + .parse::() + .unwrap_or(0) + .cmp(&b.cycle_info.cycle.parse::().unwrap_or(0)) + { + Ordering::Greater => a, + Ordering::Less => b, + Ordering::Equal => { + // If they are somehow equal, compare revisions + match a + .cycle_info + .revision + .parse::() + .unwrap_or(0) + .cmp(&b.cycle_info.revision.parse::().unwrap_or(0)) + { + Ordering::Greater | Ordering::Equal => a, + Ordering::Less => b, + } + } + } + }); + + // If we somehow don't have a cycle in bundled or downloaded, return an empty instance + let Some(latest) = latest else { + return Ok(None); + }; + + // Ensure parent folder exists (ignore the result as it will return an error if it already exists) + let _ = fs::create_dir_all(WORK_NAVIGATION_DATA_FOLDER); + + // Ensure files get copied over + if latest.cycle_info_path != PathBuf::from(WORK_CYCLE_JSON_PATH) { + fs::copy(&latest.cycle_info_path, WORK_CYCLE_JSON_PATH)?; + } + if latest.db_path != PathBuf::from(WORK_DB_PATH) { + fs::copy(&latest.db_path, WORK_DB_PATH)?; + } + + // The only way this can fail (since we know now that the path is valid) is if the file is corrupt, in which case we should report to sentry + Ok(Some(Self::open_database()?)) + } + + fn open_database() -> Result { + // We have to open with flags because the SQLITE_OPEN_CREATE flag with the default open causes the file to + // be overwritten + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX; + + // The WORK_DB_PATH is the "master" SQLite path. We have logic copying over bundled navigation data if needed in init_database. + let conn = Connection::open_with_flags(WORK_DB_PATH, flags)?; + + // Use memory for temp storage (avoids directory issues with the work folder, with the tradeoff of higher memory usage for queries) + conn.execute_batch("PRAGMA temp_store = MEMORY")?; + + Ok(conn) + } + + fn get_cycle_info() -> Result { + // The WORK_CYCLE_JSON_PATH is the "master" cycle JSON path. + CycleInfo::from_path(Path::new(WORK_CYCLE_JSON_PATH)) + } + + fn installed_path() -> Option { + Some(WORK_DB_PATH.to_owned()) + } + + async fn get_latest_cycle() -> Result> { + // Try to get the latest available cycle from our API. Support cases in which the user may be offline by returning a None instead + let latest_cycle = if let Ok(res) = NetworkRequestBuilder::new(LATEST_CYCLE_ENDPOINT) + .context("can't create new NetworkRequestBuilder")? + .get() + .context(".get() returned None")? + .wait_for_data() + .await + { + let response_info = serde_json::from_slice::(&res) + .context("can't deserialize cycle response info")?; + + Some(response_info.cycle) + } else { + None + }; + + Ok(latest_cycle) + } + + /// Download the navigation data zip file to the temp file location + async fn download_navigation_data(url: &str) -> Result<()> { + // Figure out total size of download (this request is acting like a HEAD since we don't have those in this environment. Nothing actually gets downloaded since we are constraining the range) + let request = NetworkRequestBuilder::new(url) + .context("can't create new NetworkRequestBuilder")? + .with_header(&format!("Range: bytes=0-0")) + .context(".with_header() returned None")? + .get() + .context(".get() returned None")?; + + request + .wait_for_data() + .await + .context("can't wait for head request data")?; + + // Try parsing the content-range header + let total_bytes = request + .header_section("content-range") + .context("no content-range header")? + .trim() + .split("/") + .last() + .context("invalid content-range")? + .parse::() + .context("can't parse content-range total bytes")?; + + // Total amount of chunks to download. We need to download the data in chunks of DOWNLOAD_CHUNK_SIZE_BYTES to avoid a timeout, so we need to keep track of a "working" accumulation of all responses + let total_chunks = total_bytes.div_ceil(DOWNLOAD_CHUNK_SIZE_BYTES); + + // Store the download to a file to avoid holding in-memory + let mut download_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(DOWNLOAD_TEMP_FILE_PATH) + .context("can't open temp download file")?; + + for i in 0..total_chunks { + // Calculate the range for the current chunk + let range_start = i * DOWNLOAD_CHUNK_SIZE_BYTES; + let range_end = ((i + 1) * DOWNLOAD_CHUNK_SIZE_BYTES - 1).min(total_bytes - 1); + + // Report the current download progress + InterfaceEvent::send_download_progress_event(DownloadProgressEvent { + total_bytes, + downloaded_bytes: range_start, + current_chunk: i, + total_chunks, + }) + .context("can't send download progress event")?; + + // Dispatch the request + let data = NetworkRequestBuilder::new(url) + .context("can't create new NetworkRequestBuilder")? + .with_header(&format!("Range: bytes={range_start}-{range_end}")) + .context(".with_header() returned None")? + .get() + .context(".get() returned None")? + .wait_for_data() + .await + .context("can't wait for chunk request data")?; + + // Write to limit how much data we hold in memory at a time (will be a max of DOWNLOAD_CHUNK_SIZE_BYTES) + download_file + .write_all(&data) + .context("can't write chunk to temp download file")?; + } + + Ok(()) + } + + /// Extract the navigation data files from the zip file located in the temp location + async fn install_downloaded_navigation_data() -> Result<()> { + // Load the zip archive + let zip_file = + File::open(DOWNLOAD_TEMP_FILE_PATH).context("can't open temp download file")?; + let mut zip = ZipArchive::new(BufReader::new(zip_file)) + .context("can't read zip archive from temp download file")?; + + // Ensure parent folder exists (ignore the result as it will return an error if it already exists) + let _ = fs::create_dir_all(WORK_NAVIGATION_DATA_FOLDER); + + // Write the cycle.json file + let mut cycle_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(WORK_CYCLE_JSON_PATH) + .context("can't open cycle.json work path")?; + + let mut zip_cycle = zip + .by_name("cycle.json") + .context("can't find cycle.json in zip")?; + std::io::copy(&mut zip_cycle, &mut cycle_file) + .context("can't copy cycle.json from zip to work path")?; + drop(zip_cycle); + + // Write the db file + let db_name = zip + .file_names() + .find(|f| f.to_lowercase().ends_with(".s3db")) + .context("unable to find sqlite db in downloaded zip")? + .to_owned(); + + let mut db_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(WORK_DB_PATH) + .context("can't open db work path")?; + + let mut zip_db = zip.by_name(&db_name).context("can't find db in zip")?; + std::io::copy(&mut zip_db, &mut db_file) + .context("can't copy db from zip to work path")?; + drop(zip_db); + drop(zip); + + // Remove the temp file + fs::remove_file(DOWNLOAD_TEMP_FILE_PATH).context("can't remove temp download file")?; + + Ok(()) + } +} diff --git a/src/wasm/src/sentry_gauge.rs b/src/wasm/src/platform/msfs/sentry_gauge.rs similarity index 96% rename from src/wasm/src/sentry_gauge.rs rename to src/wasm/src/platform/msfs/sentry_gauge.rs index ee197d77..6fdd89ca 100644 --- a/src/wasm/src/sentry_gauge.rs +++ b/src/wasm/src/platform/msfs/sentry_gauge.rs @@ -1,360 +1,360 @@ -use std::{ - fs::OpenOptions, - sync::{Arc, Mutex}, - time::{Duration, Instant}, -}; - -use anyhow::{anyhow, Context, Result}; -use dotenv_codegen::dotenv; -use msfs::{ - network::{NetworkRequest, NetworkRequestBuilder, NetworkRequestState}, - MSFSEvent, -}; -use once_cell::sync::Lazy; -use sentry::integrations::anyhow::capture_anyhow; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::config::Config; - -/// The path to the sentry persistent state file -const SENTRY_FILE: &str = "\\work/ng_sentry.json"; - -/// The amount of seconds between forced Sentry flushes -const SENTRY_FLUSH_INTERVAL_SECONDS: u64 = 60; - -/// The global Sentry state instance -static SENTRY_STATE: Lazy> = - Lazy::new(|| Mutex::new(SentryPersistentState::load())); - -/// A pending sentry report -#[derive(Deserialize, Serialize)] -struct PendingSentryReport { - url: String, - auth: String, - data: String, - #[serde(skip_serializing, skip_deserializing)] - request: Option, -} - -impl PendingSentryReport { - /// Create a new report - /// - /// * `url` - The URL to post to - /// * `auth` - The auth header value - /// * `data` - The data to post - pub fn new(url: String, auth: String, data: String) -> Self { - Self { - url, - auth, - data, - request: None, - } - } - - /// Send the request to Sentry - pub fn send(&mut self) -> Result<()> { - let res = NetworkRequestBuilder::new(&self.url) - .context("can't create new NetworkRequestBuilder")? - .with_header(&format!("X-Sentry-Auth: {}", self.auth)) - .context(".with_header() returned None")? - .post(&self.data) - .ok_or(anyhow!("Could not send Sentry report"))?; - - self.request.replace(res); - - Ok(()) - } -} - -/// The persistent state of sentry across "boots" of the interface -#[derive(Default, Deserialize, Serialize)] -struct SentryPersistentState { - /// The user ID - /// - /// Note: this exposes nothing about the user - it is a random UUID generated at first boot in order to keep track of the errors from the user across boots - user_id: Uuid, - /// The outgoing sentry reports - /// - /// On panic, network requests aren't able to go out. This solves that issue by storing all pending requests to the file system and retrying them on next load - reports: Vec, -} - -impl SentryPersistentState { - /// Load the state - pub fn load() -> Self { - // Read from the local file. If that fails, it is likely that the file doesn't exist, so we can just create an empty state - let status = std::fs::read_to_string(SENTRY_FILE) - .ok() - .and_then(|contents| serde_json::from_str(&contents).ok()) - .unwrap_or(SentryPersistentState { - user_id: Uuid::new_v4(), - reports: vec![], - }); - - status - } - - /// Flush all pending requests to the file system - pub fn flush(&self) -> Result<()> { - let file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(SENTRY_FILE)?; - - serde_json::to_writer(file, &self)?; - - Ok(()) - } - - /// Save a report - /// - /// * `url` - The URL to post to - /// * `auth` - The auth header value - /// * `data` - The data to post - pub fn save_report(&mut self, url: String, auth: String, data: String) -> Result<()> { - let report = PendingSentryReport::new(url, auth, data); - self.reports.push(report); - self.flush()?; - - Ok(()) - } - - /// Get the number of current pending reports - pub fn num_pending_reports(&self) -> usize { - self.reports.len() - } - - /// Main update callback for processing reports - /// - /// Note: This MUST be called every frame, otherwise we *will* miss state updates on requests as DataReady is only available for a single frame - pub fn update(&mut self) -> Result<()> { - let initial_reports_size = self.reports.len(); - - self.reports.retain_mut(|r| { - // Get the request in the report. If one does not exist, create a request - let Some(request) = r.request else { - // Create the request. If it fails, just drop this report from the state as it's likely something is wrong - return r.send().is_ok(); - }; - - // Retain if the state is not data ready - request.state() != NetworkRequestState::DataReady - }); - - // Only flush if reports size changed - if self.reports.len() != initial_reports_size { - self.flush()?; - } - - Ok(()) - } -} - -impl Drop for SentryPersistentState { - fn drop(&mut self) { - // Ensure we have the latest state reflected in the file system - if let Err(e) = self.flush() { - println!("[NAVIGRAPH]: Error on SentryPersistentState drop: {e}"); - } - } -} - -/// The transport implementation for Sentry -struct MsfsSentryTransport { - options: sentry::ClientOptions, -} - -impl MsfsSentryTransport { - fn try_send_envelope(&self, envelope: sentry::Envelope) -> Result<()> { - // Get the body - let mut body = Vec::new(); - envelope.to_writer(&mut body)?; - - // Get the URL and auth header - let dsn = self.options.dsn.as_ref().context("can't get dsn")?; - let user_agent = self.options.user_agent.clone(); - let auth = dsn.to_auth(Some(&user_agent)).to_string(); - let url = dsn.envelope_api_url().to_string(); - - // Save to the persistent state - SENTRY_STATE - .try_lock() - .map_err(|_| anyhow!("Unable to lock SENTRY_STATE"))? - .save_report(url.clone(), auth.clone(), String::from_utf8(body.clone())?)?; - - Ok(()) - } -} - -impl sentry::Transport for MsfsSentryTransport { - fn send_envelope(&self, envelope: sentry::Envelope) { - // Try to send the envelope. If this fails, it is likely due to an issue with the sentry SDK itself and we should not try reporting the error to sentry as we would end up in a continuous loop here - match self.try_send_envelope(envelope) { - Ok(_) => {} - Err(e) => { - println!("[NAVIGRAPH]: Unable to send sentry report due to error: {e}") - } - } - } -} - -/// The factory implementation for Sentry -struct MsfsSentryTransportFactory; - -impl sentry::TransportFactory for MsfsSentryTransportFactory { - fn create_transport( - &self, - options: &sentry::ClientOptions, - ) -> std::sync::Arc { - Arc::new(MsfsSentryTransport { - options: options.clone(), - }) - } -} - -/// A trait that represents the interface for a Gauge that reports errors to Sentry -pub trait SentryGauge { - fn initialize() -> Result - where - Self: Sized; - fn update(&mut self) -> Result<()>; -} - -/// Create a sentry "executor" around a gauge -/// -/// Note: This MUST be the first function called within a gauge callback. Nothing will run after this -pub async fn wrap_gauge_with_sentry(mut gauge: msfs::Gauge) -> Result<()> -where - T: SentryGauge, -{ - // Wait out the first few events as it is unreliable to call initialization logic then - while let Some(event) = gauge.next_event().await { - if matches!(event, MSFSEvent::PostInstall) { - break; - } - } - - // Initialize sentry - let sentry_guard = sentry::init(( - dotenv!("SENTRY_URL"), - sentry::ClientOptions { - release: sentry::release_name!(), - transport: Some(Arc::new(MsfsSentryTransportFactory)), - ..Default::default() - }, - )); - - // Get the user ID from persistent state - let user_id = SENTRY_STATE - .try_lock() - .map_err(|_| anyhow!("Unable to lock SENTRY_STATE"))? - .user_id - .to_string(); - - // Configure the sentry scope to report the user ID and addon info - sentry::configure_scope(|scope| { - scope.set_user(Some(sentry::User { - id: Some(user_id), - ..Default::default() - })); - - // Build specific settings - scope.set_tag("git_sha", env!("VERGEN_GIT_SHA")); - scope.set_tag("git_dirty", env!("VERGEN_GIT_DIRTY")); - scope.set_tag("built_at", env!("VERGEN_BUILD_TIMESTAMP")); - - // Addon specific settings - let config = Config::get_config(); - scope.set_tag( - "developer", - config.as_ref().map_or("unknown", |c| &c.addon.developer), - ); - scope.set_tag( - "product", - config.as_ref().map_or("unknown", |c| &c.addon.product), - ); - }); - - // Drain any pending reports. We need to structure it like this as opposed to just a top level `let Ok(state) = ...`` due to the fact we should not be holding a MutexGuard across an await point - loop { - let has_pending = { - if let Ok(state) = SENTRY_STATE.try_lock() { - state.num_pending_reports() > 0 - } else { - return Err(anyhow!("Unable to lock SENTRY_STATE")); - } - }; - - if !has_pending { - break; - } - - gauge.next_event().await; - - if let Ok(mut state) = SENTRY_STATE.try_lock() { - state.update()?; - } else { - return Err(anyhow!("Unable to lock SENTRY_STATE")); - } - } - - // Create the gauge instance - let mut instance = match T::initialize() { - Ok(instance) => instance, - Err(e) => { - capture_anyhow(&e); - return Err(e); - } - }; - - // Keep track of when we flush - let mut last_sentry_flush = Instant::now(); - - // Finally, go to the main event loop - while let Some(event) = gauge.next_event().await { - // Ensure the event is what we are looking for - let MSFSEvent::PreUpdate = event else { - continue; - }; - - // Update sentry state - if let Ok(mut state) = SENTRY_STATE.try_lock() { - state.update()?; - } else { - return Err(anyhow!("Unable to lock SENTRY_STATE")); - }; - - // Update the gauge - match instance.update() { - Ok(_) => {} - Err(e) => { - capture_anyhow(&e); - println!("[NAVIGRAPH]: Error encountered in update: {e}") - } - }; - - // Flush sentry if interval has passed - if last_sentry_flush.elapsed() >= Duration::from_secs(SENTRY_FLUSH_INTERVAL_SECONDS) { - sentry_guard.flush(None); - last_sentry_flush = Instant::now(); - } - } - - Ok(()) -} - -/// A convenience macro to handle the gauge entrypoint and sentry wrapping around a struct that implements `SentryGauge` -/// -/// Example: `sentry_gauge!(MyGauge, my_gauge_name)` -#[macro_export] -macro_rules! sentry_gauge { - ($type_:ty, $name:expr) => { - #[msfs::gauge(name = $name)] - async fn main(gauge: msfs::Gauge) -> Result<(), Box> { - wrap_gauge_with_sentry::<$type_>(gauge).await?; - Ok(()) - } - }; -} +use std::{ + fs::OpenOptions, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use anyhow::{anyhow, Context, Result}; +use dotenv_codegen::dotenv; +use msfs::{ + network::{NetworkRequest, NetworkRequestBuilder, NetworkRequestState}, + MSFSEvent, +}; +use once_cell::sync::Lazy; +use sentry::integrations::anyhow::capture_anyhow; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::config::Config; + +/// The path to the sentry persistent state file +const SENTRY_FILE: &str = "\\work/ng_sentry.json"; + +/// The amount of seconds between forced Sentry flushes +const SENTRY_FLUSH_INTERVAL_SECONDS: u64 = 60; + +/// The global Sentry state instance +static SENTRY_STATE: Lazy> = + Lazy::new(|| Mutex::new(SentryPersistentState::load())); + +/// A pending sentry report +#[derive(Deserialize, Serialize)] +struct PendingSentryReport { + url: String, + auth: String, + data: String, + #[serde(skip_serializing, skip_deserializing)] + request: Option, +} + +impl PendingSentryReport { + /// Create a new report + /// + /// * `url` - The URL to post to + /// * `auth` - The auth header value + /// * `data` - The data to post + pub fn new(url: String, auth: String, data: String) -> Self { + Self { + url, + auth, + data, + request: None, + } + } + + /// Send the request to Sentry + pub fn send(&mut self) -> Result<()> { + let res = NetworkRequestBuilder::new(&self.url) + .context("can't create new NetworkRequestBuilder")? + .with_header(&format!("X-Sentry-Auth: {}", self.auth)) + .context(".with_header() returned None")? + .post(&self.data) + .ok_or(anyhow!("Could not send Sentry report"))?; + + self.request.replace(res); + + Ok(()) + } +} + +/// The persistent state of sentry across "boots" of the interface +#[derive(Default, Deserialize, Serialize)] +struct SentryPersistentState { + /// The user ID + /// + /// Note: this exposes nothing about the user - it is a random UUID generated at first boot in order to keep track of the errors from the user across boots + user_id: Uuid, + /// The outgoing sentry reports + /// + /// On panic, network requests aren't able to go out. This solves that issue by storing all pending requests to the file system and retrying them on next load + reports: Vec, +} + +impl SentryPersistentState { + /// Load the state + pub fn load() -> Self { + // Read from the local file. If that fails, it is likely that the file doesn't exist, so we can just create an empty state + let status = std::fs::read_to_string(SENTRY_FILE) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) + .unwrap_or(SentryPersistentState { + user_id: Uuid::new_v4(), + reports: vec![], + }); + + status + } + + /// Flush all pending requests to the file system + pub fn flush(&self) -> Result<()> { + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(SENTRY_FILE)?; + + serde_json::to_writer(file, &self)?; + + Ok(()) + } + + /// Save a report + /// + /// * `url` - The URL to post to + /// * `auth` - The auth header value + /// * `data` - The data to post + pub fn save_report(&mut self, url: String, auth: String, data: String) -> Result<()> { + let report = PendingSentryReport::new(url, auth, data); + self.reports.push(report); + self.flush()?; + + Ok(()) + } + + /// Get the number of current pending reports + pub fn num_pending_reports(&self) -> usize { + self.reports.len() + } + + /// Main update callback for processing reports + /// + /// Note: This MUST be called every frame, otherwise we *will* miss state updates on requests as DataReady is only available for a single frame + pub fn update(&mut self) -> Result<()> { + let initial_reports_size = self.reports.len(); + + self.reports.retain_mut(|r| { + // Get the request in the report. If one does not exist, create a request + let Some(request) = r.request else { + // Create the request. If it fails, just drop this report from the state as it's likely something is wrong + return r.send().is_ok(); + }; + + // Retain if the state is not data ready + request.state() != NetworkRequestState::DataReady + }); + + // Only flush if reports size changed + if self.reports.len() != initial_reports_size { + self.flush()?; + } + + Ok(()) + } +} + +impl Drop for SentryPersistentState { + fn drop(&mut self) { + // Ensure we have the latest state reflected in the file system + if let Err(e) = self.flush() { + println!("[NAVIGRAPH]: Error on SentryPersistentState drop: {e}"); + } + } +} + +/// The transport implementation for Sentry +struct MsfsSentryTransport { + options: sentry::ClientOptions, +} + +impl MsfsSentryTransport { + fn try_send_envelope(&self, envelope: sentry::Envelope) -> Result<()> { + // Get the body + let mut body = Vec::new(); + envelope.to_writer(&mut body)?; + + // Get the URL and auth header + let dsn = self.options.dsn.as_ref().context("can't get dsn")?; + let user_agent = self.options.user_agent.clone(); + let auth = dsn.to_auth(Some(&user_agent)).to_string(); + let url = dsn.envelope_api_url().to_string(); + + // Save to the persistent state + SENTRY_STATE + .try_lock() + .map_err(|_| anyhow!("Unable to lock SENTRY_STATE"))? + .save_report(url.clone(), auth.clone(), String::from_utf8(body.clone())?)?; + + Ok(()) + } +} + +impl sentry::Transport for MsfsSentryTransport { + fn send_envelope(&self, envelope: sentry::Envelope) { + // Try to send the envelope. If this fails, it is likely due to an issue with the sentry SDK itself and we should not try reporting the error to sentry as we would end up in a continuous loop here + match self.try_send_envelope(envelope) { + Ok(_) => {} + Err(e) => { + println!("[NAVIGRAPH]: Unable to send sentry report due to error: {e}") + } + } + } +} + +/// The factory implementation for Sentry +struct MsfsSentryTransportFactory; + +impl sentry::TransportFactory for MsfsSentryTransportFactory { + fn create_transport( + &self, + options: &sentry::ClientOptions, + ) -> std::sync::Arc { + Arc::new(MsfsSentryTransport { + options: options.clone(), + }) + } +} + +/// A trait that represents the interface for a Gauge that reports errors to Sentry +pub trait SentryGauge { + fn initialize() -> Result + where + Self: Sized; + fn update(&mut self) -> Result<()>; +} + +/// Create a sentry "executor" around a gauge +/// +/// Note: This MUST be the first function called within a gauge callback. Nothing will run after this +pub async fn wrap_gauge_with_sentry(mut gauge: msfs::Gauge) -> Result<()> +where + T: SentryGauge, +{ + // Wait out the first few events as it is unreliable to call initialization logic then + while let Some(event) = gauge.next_event().await { + if matches!(event, MSFSEvent::PostInstall) { + break; + } + } + + // Initialize sentry + let sentry_guard = sentry::init(( + dotenv!("SENTRY_URL"), + sentry::ClientOptions { + release: sentry::release_name!(), + transport: Some(Arc::new(MsfsSentryTransportFactory)), + ..Default::default() + }, + )); + + // Get the user ID from persistent state + let user_id = SENTRY_STATE + .try_lock() + .map_err(|_| anyhow!("Unable to lock SENTRY_STATE"))? + .user_id + .to_string(); + + // Configure the sentry scope to report the user ID and addon info + sentry::configure_scope(|scope| { + scope.set_user(Some(sentry::User { + id: Some(user_id), + ..Default::default() + })); + + // Build specific settings + scope.set_tag("git_sha", env!("VERGEN_GIT_SHA")); + scope.set_tag("git_dirty", env!("VERGEN_GIT_DIRTY")); + scope.set_tag("built_at", env!("VERGEN_BUILD_TIMESTAMP")); + + // Addon specific settings + let config = Config::get_config(); + scope.set_tag( + "developer", + config.as_ref().map_or("unknown", |c| &c.addon.developer), + ); + scope.set_tag( + "product", + config.as_ref().map_or("unknown", |c| &c.addon.product), + ); + }); + + // Drain any pending reports. We need to structure it like this as opposed to just a top level `let Ok(state) = ...`` due to the fact we should not be holding a MutexGuard across an await point + loop { + let has_pending = { + if let Ok(state) = SENTRY_STATE.try_lock() { + state.num_pending_reports() > 0 + } else { + return Err(anyhow!("Unable to lock SENTRY_STATE")); + } + }; + + if !has_pending { + break; + } + + gauge.next_event().await; + + if let Ok(mut state) = SENTRY_STATE.try_lock() { + state.update()?; + } else { + return Err(anyhow!("Unable to lock SENTRY_STATE")); + } + } + + // Create the gauge instance + let mut instance = match T::initialize() { + Ok(instance) => instance, + Err(e) => { + capture_anyhow(&e); + return Err(e); + } + }; + + // Keep track of when we flush + let mut last_sentry_flush = Instant::now(); + + // Finally, go to the main event loop + while let Some(event) = gauge.next_event().await { + // Ensure the event is what we are looking for + let MSFSEvent::PreUpdate = event else { + continue; + }; + + // Update sentry state + if let Ok(mut state) = SENTRY_STATE.try_lock() { + state.update()?; + } else { + return Err(anyhow!("Unable to lock SENTRY_STATE")); + }; + + // Update the gauge + match instance.update() { + Ok(_) => {} + Err(e) => { + capture_anyhow(&e); + println!("[NAVIGRAPH]: Error encountered in update: {e}") + } + }; + + // Flush sentry if interval has passed + if last_sentry_flush.elapsed() >= Duration::from_secs(SENTRY_FLUSH_INTERVAL_SECONDS) { + sentry_guard.flush(None); + last_sentry_flush = Instant::now(); + } + } + + Ok(()) +} + +/// A convenience macro to handle the gauge entrypoint and sentry wrapping around a struct that implements `SentryGauge` +/// +/// Example: `sentry_gauge!(MyGauge, my_gauge_name)` +#[macro_export] +macro_rules! sentry_gauge { + ($type_:ty, $name:expr) => { + #[msfs::gauge(name = $name)] + async fn main(gauge: msfs::Gauge) -> Result<(), Box> { + wrap_gauge_with_sentry::<$type_>(gauge).await?; + Ok(()) + } + }; +} diff --git a/src/wasm/src/platform/standalone/exports.rs b/src/wasm/src/platform/standalone/exports.rs new file mode 100644 index 00000000..022482fa --- /dev/null +++ b/src/wasm/src/platform/standalone/exports.rs @@ -0,0 +1,73 @@ +//! The ABI of the standalone build, used by the `StandaloneTransport` in the TS package. +//! +//! The host drives the module the same way the sim drives the gauge: +//! - `navigraph_call_function` is the equivalent of a `NAVIGRAPH_CallFunction` commbus call +//! - `navigraph_update` is the equivalent of a sim frame +//! - Results and events are passed back through the imported `navigraph.send_message`, with the same channels and payloads as the commbus + +use std::cell::RefCell; + +use crate::interface::FunctionQueue; + +thread_local! { + static FUNCTION_QUEUE: RefCell = RefCell::new(FunctionQueue::default()); +} + +/// Allocate a buffer of `len` bytes for the host to write into +#[no_mangle] +pub extern "C" fn navigraph_alloc(len: usize) -> *mut u8 { + let mut buffer = Vec::::with_capacity(len); + let ptr = buffer.as_mut_ptr(); + std::mem::forget(buffer); + ptr +} + +/// Free a buffer allocated with `navigraph_alloc` which was not passed to `navigraph_call_function` +/// +/// # Safety +/// `ptr` and `len` must come from a single call to `navigraph_alloc` +#[no_mangle] +pub unsafe extern "C" fn navigraph_dealloc(ptr: *mut u8, len: usize) { + drop(Vec::from_raw_parts(ptr, 0, len)); +} + +/// Queue a function call. The payload has the same format as a `NAVIGRAPH_CallFunction` commbus call. Takes ownership of the buffer. +/// +/// # Safety +/// `ptr` and `len` must come from a single call to `navigraph_alloc`, with all `len` bytes written +#[no_mangle] +pub unsafe extern "C" fn navigraph_call_function(ptr: *mut u8, len: usize) { + let payload = Vec::from_raw_parts(ptr, len, len); + + let Ok(args) = String::from_utf8(payload) else { + sentry::capture_message( + "NAVIGRAPH_CallFunction payload is not valid UTF-8", + sentry::Level::Warning, + ); + return; + }; + + FUNCTION_QUEUE.with(|queue| match queue.try_borrow_mut() { + Ok(mut queue) => queue.push(&args), + Err(_) => { + sentry::capture_message( + "Unable to borrow processing queue", + sentry::Level::Warning, + ); + } + }); +} + +/// Run queued functions and send the heartbeat when due. The host should call this once per frame. +#[no_mangle] +pub extern "C" fn navigraph_update() { + FUNCTION_QUEUE.with(|queue| { + let Ok(mut queue) = queue.try_borrow_mut() else { + return; + }; + + if let Err(e) = queue.update() { + println!("[NAVIGRAPH]: Error encountered in update: {e}"); + } + }); +} diff --git a/src/wasm/src/platform/standalone/mod.rs b/src/wasm/src/platform/standalone/mod.rs new file mode 100644 index 00000000..1d01cde5 --- /dev/null +++ b/src/wasm/src/platform/standalone/mod.rs @@ -0,0 +1,121 @@ +use std::ptr::NonNull; + +use anyhow::{Context, Result}; +use rusqlite::{ffi, serialize::OwnedData, Connection, DatabaseName}; + +use super::Platform; +use crate::{ + database::CycleInfo, + interface::{DownloadProgressEvent, InterfaceEvent}, +}; + +/// The exported entry points, called by the `StandaloneTransport` in the TS package +mod exports; + +/// The mock navigation data, generated inside the standalone build container (`bun run build:wasm:standalone`), +/// which sets `NAVIGRAPH_MOCK_DATA_DIR` to its output folder +const MOCK_NAVDATA: &[u8] = include_bytes!(concat!( + env!( + "NAVIGRAPH_MOCK_DATA_DIR", + "The standalone build must run through `bun run build:wasm:standalone`, which generates the mock navigation data" + ), + "/mock-navdata.sqlite" +)); + +/// The cycle info of the mock navigation data +const MOCK_CYCLE_JSON: &str = include_str!(concat!( + env!( + "NAVIGRAPH_MOCK_DATA_DIR", + "The standalone build must run through `bun run build:wasm:standalone`, which generates the mock navigation data" + ), + "/mock-cycle.json" +)); + +/// The path reported for the mock navigation data +const MOCK_NAVDATA_PATH: &str = "mock-navdata.sqlite"; + +/// The number of progress events sent by the mock download +const MOCK_DOWNLOAD_CHUNKS: usize = 4; + +/// The size of each chunk of the mock download +const MOCK_DOWNLOAD_CHUNK_SIZE_BYTES: usize = 4 * 1024 * 1024; + +mod host { + #[link(wasm_import_module = "navigraph")] + extern "C" { + /// Deliver a message to the host. The host copies the data before returning. + pub fn send_message( + channel_ptr: *const u8, + channel_len: usize, + data_ptr: *const u8, + data_len: usize, + ); + } +} + +/// The standalone platform: runs outside the sim, messaging the host directly and serving the embedded mock navigation data +pub(crate) struct StandalonePlatform; + +impl Platform for StandalonePlatform { + fn send_message(channel: &str, data: &str) { + // SAFETY: The pointers are valid for the given lengths for the duration of the call, and the host copies the data before returning + unsafe { host::send_message(channel.as_ptr(), channel.len(), data.as_ptr(), data.len()) } + } + + fn init_database() -> Result> { + Ok(Some(Self::open_database()?)) + } + + fn open_database() -> Result { + let mut conn = Connection::open_in_memory()?; + + // Load the embedded database into memory. SQLite takes ownership of the buffer, which must be allocated with sqlite3_malloc. + // SAFETY: The buffer is allocated by SQLite with the required size, and fully initialized before being handed over + let data = unsafe { + let ptr = ffi::sqlite3_malloc64(MOCK_NAVDATA.len() as u64) as *mut u8; + let ptr = NonNull::new(ptr).context("can't allocate memory for mock navigation data")?; + std::ptr::copy_nonoverlapping(MOCK_NAVDATA.as_ptr(), ptr.as_ptr(), MOCK_NAVDATA.len()); + OwnedData::from_raw_nonnull(ptr, MOCK_NAVDATA.len()) + }; + conn.deserialize(DatabaseName::Main, data, true) + .context("can't load mock navigation data")?; + + // Keep temp storage in memory, as there is no filesystem to rely on + conn.execute_batch("PRAGMA temp_store = MEMORY")?; + + Ok(conn) + } + + fn get_cycle_info() -> Result { + CycleInfo::from_json(MOCK_CYCLE_JSON) + } + + fn installed_path() -> Option { + Some(MOCK_NAVDATA_PATH.to_owned()) + } + + async fn get_latest_cycle() -> Result> { + Ok(Some(Self::get_cycle_info()?.cycle)) + } + + /// Simulates a download by sending progress events. The mock navigation data is reloaded when the connection is reopened. + async fn download_navigation_data(_url: &str) -> Result<()> { + let total_bytes = MOCK_DOWNLOAD_CHUNKS * MOCK_DOWNLOAD_CHUNK_SIZE_BYTES; + + for i in 0..MOCK_DOWNLOAD_CHUNKS { + InterfaceEvent::send_download_progress_event(DownloadProgressEvent { + total_bytes, + downloaded_bytes: i * MOCK_DOWNLOAD_CHUNK_SIZE_BYTES, + current_chunk: i, + total_chunks: MOCK_DOWNLOAD_CHUNKS, + }) + .context("can't send download progress event")?; + } + + Ok(()) + } + + async fn install_downloaded_navigation_data() -> Result<()> { + Ok(()) + } +}