diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8c0f40b..94908cc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -140,6 +140,11 @@ jobs: profile: minimal override: true + - name: Set up Node.js (for websocket interop tests) + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Create dummy local.properties run: | echo 'sdk.dir=/fake' >> local.properties @@ -173,14 +178,14 @@ jobs: env: JAVA_HOME: ${{ env.JAVA_HOME_FOR_BUILD }} with: - arguments: lib:assemble -Penv=dev --info + arguments: lib:assemble websocket:assemble -Penv=dev --info - name: Test with Java 21 runtime uses: gradle/gradle-build-action@749f47bda3e44aa060e82d7b3ef7e40d953bd629 env: JAVA_HOME: ${{ env.JAVA_HOME_FOR_BUILD }} with: - arguments: lib:test -Penv=dev --info + arguments: lib:test websocket:test -Penv=dev --info - name: Test with Java 8 runtime (backward compatibility) uses: gradle/gradle-build-action@749f47bda3e44aa060e82d7b3ef7e40d953bd629 diff --git a/settings.gradle.kts b/settings.gradle.kts index 93a7ffc..5122488 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,5 +20,6 @@ pluginManagement { rootProject.name = "automerge-java" include("lib") +include("websocket") include("android") include("android-test-app") diff --git a/websocket/build.gradle.kts b/websocket/build.gradle.kts new file mode 100644 index 0000000..2a54d41 --- /dev/null +++ b/websocket/build.gradle.kts @@ -0,0 +1,97 @@ +plugins { + `java-library` + id("org.danilopianini.publish-on-central") + id("com.diffplug.spotless") +} + +java { + withJavadocJar() + withSourcesJar() + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +repositories { + mavenCentral() +} + +dependencies { + api(project(":lib")) + implementation("org.java-websocket:Java-WebSocket:1.5.7") + implementation("org.slf4j:slf4j-api:2.0.9") + + testImplementation(platform("org.junit:junit-bom:5.11.4")) + testImplementation("org.junit.jupiter:junit-jupiter-api") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.slf4j:slf4j-simple:2.0.9") +} + +spotless { + java { + importOrder() + targetExclude("src/templates/*") + removeUnusedImports() + cleanthat() + eclipse().configFile("${project.rootDir}/spotless.eclipseformat.xml") + formatAnnotations() + } +} + +publishOnCentral { + projectDescription.set("WebSocket network transport for Automerge repos") + projectLongName.set("Automerge WebSocket") +} + +publishing { + publications { + withType { + artifactId = "automerge-websocket" + } + } +} + +val env = providers.gradleProperty("env").getOrElse("release") +val isDev = env == "dev" + +if (isDev) { + tasks.register("compileRustForTest") { + workingDir = File("../rust") + commandLine = listOf("cargo", "build") + } + + val version = (project.extra.get("libVersionSuffix") as String) + + tasks.register("createVersionedLibForTest") { + dependsOn("compileRustForTest") + val debugDir = file("../rust/target/debug") + doLast { + listOf("libautomerge_jni" to "so", "libautomerge_jni" to "dylib", "automerge_jni" to "dll").forEach { (base, ext) -> + val src = debugDir.resolve("$base.$ext") + if (src.exists()) { + src.copyTo(debugDir.resolve("${base}_$version.$ext"), overwrite = true) + } + } + } + } + + tasks.withType { + dependsOn("createVersionedLibForTest") + systemProperty("java.library.path", file("../rust/target/debug").absolutePath) + } +} + +tasks.compileJava { + options.release = 8 +} + +tasks.test { + useJUnitPlatform() + // Run tests with the websocket module dir as working dir so that + // JsServerWrapper can find interop-test-server/ via a relative path. + workingDir = projectDir + testLogging { + showStandardStreams = true + } +} diff --git a/websocket/interop-test-server/.gitignore b/websocket/interop-test-server/.gitignore new file mode 100644 index 0000000..23a608b --- /dev/null +++ b/websocket/interop-test-server/.gitignore @@ -0,0 +1,2 @@ +node_modules +*.js diff --git a/websocket/interop-test-server/client.ts b/websocket/interop-test-server/client.ts new file mode 100644 index 0000000..321fec1 --- /dev/null +++ b/websocket/interop-test-server/client.ts @@ -0,0 +1,222 @@ +import { + Repo, + isValidAutomergeUrl, + parseAutomergeUrl, +} from "@automerge/automerge-repo"; +import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import { command, run, string, positional, number, subcommands } from "cmd-ts"; +import { next as A } from "@automerge/automerge"; + +const create = command({ + name: "create", + args: { + port: positional({ + type: number, + displayName: "port", + description: "The port to connect to", + }), + }, + handler: ({ port }) => { + const repo = new Repo({ + network: [new BrowserWebSocketClientAdapter(`ws://localhost:${port}`)], + }); + const doc = repo.create<{ foo: string }>(); + doc.change((d) => (d.foo = "bar")); + console.log(doc.url); + console.log(A.getHeads(doc.doc()).join(",")); + }, +}); + +const fetch = command({ + name: "fetch", + args: { + port: positional({ + type: number, + displayName: "port", + description: "The port to connect to", + }), + docUrl: positional({ + type: string, + displayName: "docUrl", + description: "The document url to fetch", + }), + }, + handler: ({ port, docUrl }) => { + const repo = new Repo({ + network: [new BrowserWebSocketClientAdapter(`ws://localhost:${port}`)], + }); + if (isValidAutomergeUrl(docUrl)) { + } else { + throw new Error("Invalid docUrl"); + } + const doc = repo.find(docUrl); + repo.find(docUrl).then((d) => console.log(A.getHeads(d.doc()).join(","))); + }, +}); + +const sendEphemeral = command({ + name: "send-ephemeral", + args: { + port: positional({ + type: number, + displayName: "port", + description: "The port to connect to", + }), + docUrl: positional({ + type: string, + displayName: "docUrl", + description: "The document url to fetch", + }), + message: positional({ + type: string, + displayName: "message", + description: "The message to send", + }), + }, + handler: ({ port, docUrl, message }) => { + const repo = new Repo({ + network: [new BrowserWebSocketClientAdapter(`ws://localhost:${port}`)], + }); + if (!isValidAutomergeUrl(docUrl)) { + throw new Error("Invalid docUrl"); + } + repo + .find(docUrl) + .then((doc) => { + doc.broadcast({ message }); + process.exit(0); + }) + .catch((e) => { + console.error(e); + process.exit(1); + }); + }, +}); + +const receiveEphemeral = command({ + name: "receive-ephemeral", + args: { + port: positional({ + type: number, + displayName: "port", + description: "The port to connect to", + }), + docUrl: positional({ + type: string, + displayName: "docUrl", + description: "The document url to fetch", + }), + }, + handler: async ({ port, docUrl }) => { + const repo = new Repo({ + network: [new BrowserWebSocketClientAdapter(`ws://localhost:${port}`)], + }); + if (!isValidAutomergeUrl(docUrl)) { + throw new Error("Invalid docUrl"); + } + repo.find(docUrl).then((doc) => { + doc.on("ephemeral-message", ({ message }) => { + if (typeof message === "object" && "message" in message) { + console.log(message.message); + } + }); + // Signal that we're ready to receive ephemeral messages + console.log("ready"); + }); + }, +}); + +// This command connects to two servers: first a JS server (which has a storage ID), syncs a +// document with it, then connects to a second server (the Java server). When the second server +// becomes a "generous peer", addGenerousPeer fires and sends a `remote-heads-changed` message +// containing the stored remote heads info with a `Date.now()` timestamp (encoded as f64 by cbor-x). +const createAndRelayHeads = command({ + name: "create-and-relay-heads", + args: { + jsServerPort: positional({ + type: number, + displayName: "jsServerPort", + description: "The port of the JS server to sync with first", + }), + javaServerPort: positional({ + type: number, + displayName: "javaServerPort", + description: "The port of the Java server to connect to second", + }), + }, + handler: async ({ jsServerPort, javaServerPort }) => { + const jsAdapter = new BrowserWebSocketClientAdapter( + `ws://localhost:${jsServerPort}`, + ); + const repo = new Repo({ + network: [jsAdapter], + enableRemoteHeadsGossiping: true, + }); + + // Subscribe to a dummy storage ID so that when the Java server connects, + // addGenerousPeer sends a remote-subscription-change message too. + repo.subscribeToRemotes(["dummy-storage-id" as any]); + + const doc = repo.create<{ foo: string }>(); + doc.change((d) => (d.foo = "bar")); + + // Wait for sync with JS server to complete. This stores remote heads info + // (with a Date.now() timestamp) in RemoteHeadsSubscriptions.#syncInfoByDocId. + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Now connect to the Java server. When the peer event fires, sharePolicy + // returns true (default), so addGenerousPeer is called. This sends a + // `remote-heads-changed` message with the stored f64 timestamp. + const javaAdapter = new BrowserWebSocketClientAdapter( + `ws://localhost:${javaServerPort}`, + ); + repo.networkSubsystem.addNetworkAdapter(javaAdapter); + + // Wait for the Java server connection to establish and messages to be sent. + await new Promise((resolve) => setTimeout(resolve, 500)); + + console.log(doc.url); + console.log(A.getHeads(doc.doc()).join(",")); + }, +}); + +const subscribeAndCreate = command({ + name: "subscribe-and-create", + args: { + port: positional({ + type: number, + displayName: "port", + description: "The port to connect to", + }), + storageId: positional({ + type: string, + displayName: "storageId", + description: "The storage ID to subscribe to remote heads for", + }), + }, + handler: ({ port, storageId }) => { + const repo = new Repo({ + network: [new BrowserWebSocketClientAdapter(`ws://localhost:${port}`)], + enableRemoteHeadsGossiping: true, + }); + repo.subscribeToRemotes([storageId as any]); + const doc = repo.create<{ foo: string }>(); + doc.change((d) => (d.foo = "bar")); + console.log(doc.url); + console.log(A.getHeads(doc.doc()).join(",")); + }, +}); + +const app = subcommands({ + name: "client", + cmds: { + create, + fetch, + "send-ephemeral": sendEphemeral, + "receive-ephemeral": receiveEphemeral, + "subscribe-and-create": subscribeAndCreate, + "create-and-relay-heads": createAndRelayHeads, + }, +}); + +run(app, process.argv.slice(2)); diff --git a/websocket/interop-test-server/package-lock.json b/websocket/interop-test-server/package-lock.json new file mode 100644 index 0000000..cfe2ce3 --- /dev/null +++ b/websocket/interop-test-server/package-lock.json @@ -0,0 +1,2739 @@ +{ + "name": "interop-test-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "interop-test-server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@automerge/automerge": "^2.2.9", + "@automerge/automerge-repo": "^2.0.7", + "@automerge/automerge-repo-network-websocket": "^2.0.7", + "@types/ws": "^8.18.1", + "cmd-ts": "^0.13.0", + "express": "^5.1.0", + "ws": "^8.13.0" + }, + "devDependencies": { + "@types/node": "^18.14.0", + "ts-mocha": "^10.0.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + } + }, + "node_modules/@automerge/automerge": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/@automerge/automerge/-/automerge-2.2.9.tgz", + "integrity": "sha512-6HM52Ops79hAQBWMg/t0MNfGOdEiXyenQjO9F1hKZq0RWDsMLpPa1SzRy/C4/4UyX67sTHuA5CwBpH34SpfZlA==", + "license": "MIT", + "dependencies": { + "uuid": "^9.0.0" + } + }, + "node_modules/@automerge/automerge-repo": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo/-/automerge-repo-2.0.7.tgz", + "integrity": "sha512-mesv+b5KfN3L1pQCgy2yjhC3Yvg9FttzaFdbDCQ7p/Eed44npPByiB4TXHOzf2tlehAjBB7V5ruejxCRB3NAzQ==", + "license": "MIT", + "dependencies": { + "@automerge/automerge": "^2.2.8", + "bs58check": "^3.0.1", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "fast-sha256": "^1.3.0", + "uuid": "^9.0.0", + "xstate": "^5.9.1" + } + }, + "node_modules/@automerge/automerge-repo-network-websocket": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@automerge/automerge-repo-network-websocket/-/automerge-repo-network-websocket-2.0.7.tgz", + "integrity": "sha512-sYphrn54SfEOMTtmlqc4lQlzMlv0upl8oyg3VoaSGvBIJFvnzFVRgXDuNJG5EnBHO8gT0AdwDHYXJDyaVd9IWQ==", + "license": "MIT", + "dependencies": { + "@automerge/automerge-repo": "2.0.7", + "cbor-x": "^1.3.0", + "debug": "^4.3.4", + "eventemitter3": "^5.0.1", + "isomorphic-ws": "^5.0.0", + "ws": "^8.7.0" + } + }, + "node_modules/@cbor-extract/cbor-extract-darwin-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.0.tgz", + "integrity": "sha512-P7swiOAdF7aSi0H+tHtHtr6zrpF3aAq/W9FXx5HektRvLTM2O89xCyXF3pk7pLc7QpaY7AoaE8UowVf9QBdh3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-darwin-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.0.tgz", + "integrity": "sha512-1liF6fgowph0JxBbYnAS7ZlqNYLf000Qnj4KjqPNW4GViKrEql2MgZnAsExhY9LSy8dnvA4C0qHEBgPrll0z0w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.0.tgz", + "integrity": "sha512-QeBcBXk964zOytiedMPQNZr7sg0TNavZeuUCD6ON4vEOU/25+pLhNN6EDIKJ9VLTKaZ7K7EaAriyYQ1NQ05s/Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-arm64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.0.tgz", + "integrity": "sha512-rQvhNmDuhjTVXSPFLolmQ47/ydGOFXtbR7+wgkSY0bdOxCFept1hvg59uiLPT2fVDuJFuEy16EImo5tE2x3RsQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-linux-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.0.tgz", + "integrity": "sha512-cWLAWtT3kNLHSvP4RKDzSTX9o0wvQEEAj4SKvhWuOVZxiDAeQazr9A+PSiRILK1VYMLeDml89ohxCnUNQNQNCw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cbor-extract/cbor-extract-win32-x64": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.0.tgz", + "integrity": "sha512-l2M+Z8DO2vbvADOBNLbbh9y5ST1RY5sqkWOg/58GkUPBYou/cuNZ68SGQ644f1CvZ8kcOxyZtw06+dxWHIoN/w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/node": { + "version": "18.19.115", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.115.tgz", + "integrity": "sha512-kNrFiTgG4a9JAn1LMQeLOv3MvXIPokzXziohMrMsvpYgLpdEt/mMiVYc4sGKtDfyxM5gIDF4VgrPRyCw4fHOYg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0", + "peer": true + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/base-x": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", + "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "license": "MIT", + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cbor-extract": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.0.tgz", + "integrity": "sha512-Ig1zM66BjLfTXpNgKpvBePq271BPOvu8MR0Jl080yG7Jsl+wAZunfrwiwA+9ruzm/WEdIV5QF/bjDZTqyAIVHA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.1.1" + }, + "bin": { + "download-cbor-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@cbor-extract/cbor-extract-darwin-arm64": "2.2.0", + "@cbor-extract/cbor-extract-darwin-x64": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm": "2.2.0", + "@cbor-extract/cbor-extract-linux-arm64": "2.2.0", + "@cbor-extract/cbor-extract-linux-x64": "2.2.0", + "@cbor-extract/cbor-extract-win32-x64": "2.2.0" + } + }, + "node_modules/cbor-x": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.0.tgz", + "integrity": "sha512-0kareyRwHSkL6ws5VXHEf8uY1liitysCVJjlmhaLG+IXLqhSaOO+t63coaso7yjwEzWZzLy8fJo06gZDVQM9Qg==", + "license": "MIT", + "optionalDependencies": { + "cbor-extract": "^2.2.0" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cmd-ts": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/cmd-ts/-/cmd-ts-0.13.0.tgz", + "integrity": "sha512-nsnxf6wNIM/JAS7T/x/1JmbEsjH0a8tezXqqpaL0O6+eV0/aDEnRxwjxpu0VzDdRcaC1ixGSbRlUuf/IU59I4g==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "didyoumean": "^1.2.2", + "strip-ansi": "^6.0.0" + } + }, + "node_modules/cmd-ts/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cmd-ts/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "bin": { + "flat": "cli.js" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "peer": true, + "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" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/isomorphic-ws": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz", + "integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mocha": { + "version": "11.7.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.1.tgz", + "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz", + "integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-mocha": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ts-mocha/-/ts-mocha-10.1.0.tgz", + "integrity": "sha512-T0C0Xm3/WqCuF2tpa0GNGESTBoKZaiqdUP8guNv4ZY316AFXlyidnrzQ1LUrCT0Wb1i3J0zFTgOh/55Un44WdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-node": "7.0.1" + }, + "bin": { + "ts-mocha": "bin/ts-mocha" + }, + "engines": { + "node": ">= 6.X.X" + }, + "optionalDependencies": { + "tsconfig-paths": "^3.5.0" + }, + "peerDependencies": { + "mocha": "^3.X.X || ^4.X.X || ^5.X.X || ^6.X.X || ^7.X.X || ^8.X.X || ^9.X.X || ^10.X.X || ^11.X.X" + } + }, + "node_modules/ts-mocha/node_modules/diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ts-mocha/node_modules/ts-node": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", + "integrity": "sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "arrify": "^1.0.0", + "buffer-from": "^1.1.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "minimist": "^1.2.0", + "mkdirp": "^0.5.1", + "source-map-support": "^0.5.6", + "yn": "^2.0.0" + }, + "bin": { + "ts-node": "dist/bin.js" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ts-mocha/node_modules/yn": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", + "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.3.tgz", + "integrity": "sha512-slxCaKbYjEdFT/o2rH9xS1hf4uRDch1w7Uo+apxhZ+sf/1d9e0ZVkn42kPNGP2dgjIx6YFvSevj0zHvbWe2jdw==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xstate": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.20.0.tgz", + "integrity": "sha512-lFQ0fLwVa0OMZnjNP8zUkt2fwIS85ZBNPvxE41APVYhYj0++aTssgmsnTAiBHdKYbgnd6+L8DRTZ9jPZAFPuyw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/xstate" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/websocket/interop-test-server/package.json b/websocket/interop-test-server/package.json new file mode 100644 index 0000000..e617147 --- /dev/null +++ b/websocket/interop-test-server/package.json @@ -0,0 +1,27 @@ +{ + "name": "interop-test-server", + "version": "1.0.0", + "description": "", + "main": "server.js", + "author": "", + "license": "ISC", + "type": "module", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@automerge/automerge": "^2.2.9", + "@automerge/automerge-repo": "^2.0.7", + "@automerge/automerge-repo-network-websocket": "^2.0.7", + "@types/ws": "^8.18.1", + "cmd-ts": "^0.13.0", + "express": "^5.1.0", + "ws": "^8.13.0" + }, + "devDependencies": { + "@types/node": "^18.14.0", + "ts-mocha": "^10.0.0", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" + } +} diff --git a/websocket/interop-test-server/server.ts b/websocket/interop-test-server/server.ts new file mode 100644 index 0000000..26d9e64 --- /dev/null +++ b/websocket/interop-test-server/server.ts @@ -0,0 +1,128 @@ +import express from "express"; +import { WebSocketServer } from "ws"; +import { + Chunk, + Repo, + RepoConfig, + StorageAdapterInterface, + StorageKey, +} from "@automerge/automerge-repo"; +import { NodeWSServerAdapter } from "@automerge/automerge-repo-network-websocket"; + +class Server { + #socket: WebSocketServer; + + #server: ReturnType; + #storage: InMemoryStorageAdapter; + + #repo: Repo; + + constructor(port: number) { + this.#socket = new WebSocketServer({ noServer: true }); + + const PORT = port; + const app = express(); + app.use(express.static("public")); + this.#storage = new InMemoryStorageAdapter(); + + const config: RepoConfig = { + // network: [new NodeWSServerAdapter(this.#socket) as any], + network: [new NodeWSServerAdapter(this.#socket as any)], + storage: this.#storage, + /** @ts-ignore @type {(import("automerge-repo").PeerId)} */ + peerId: `storage-server` as PeerId, + // Since this is a server, we don't share generously — meaning we only sync documents they already + // know about and can ask for by ID. + sharePolicy: async () => false, + }; + const serverRepo = new Repo(config); + this.#repo = serverRepo; + + app.get("/", (req, res) => { + res.send(`👍 @automerge/automerge-repo-sync-server is running`); + }); + + app.get("/storage-keys", (req, res) => { + const keys = this.#storage.keys(); + res.json(keys); + }); + + this.#server = app.listen(PORT, () => { + console.log(`Listening on port ${this.#server.address().port}`); + }); + + this.#server.on("upgrade", (request, socket, head) => { + console.log("upgrading to websocket"); + this.#socket.handleUpgrade(request, socket, head, (socket) => { + this.#socket.emit("connection", socket, request); + }); + }); + } + + close() { + this.#storage.log(); + this.#socket.close(); + this.#server.close(); + } +} + +class InMemoryStorageAdapter implements StorageAdapterInterface { + #data: Map = new Map(); + + async load(key: StorageKey): Promise { + return this.#data.get(key); + } + async save(key: StorageKey, data: Uint8Array): Promise { + this.#data.set(key, data); + } + async remove(key: StorageKey): Promise { + this.#data.delete(key); + } + async loadRange(keyPrefix: StorageKey): Promise { + let result: Chunk[] = []; + for (const [key, value] of this.#data.entries()) { + if (isPrefixOf(keyPrefix, key)) { + result.push({ + key, + data: value, + }); + } + } + return result; + } + + removeRange(keyPrefix: StorageKey): Promise { + for (const [key] of this.#data.entries()) { + if (isPrefixOf(keyPrefix, key)) { + this.#data.delete(key); + } + } + return Promise.resolve(); + } + + keys(): string[][] { + return Array.from(this.#data.keys()); + } + + log() { + console.log(`InMemoryStorageAdapter has ${this.#data.size} items:`); + for (const [key, value] of this.#data.entries()) { + console.log(` ${key.join("/")}: ${value.length} bytes`); + } + } +} + +function isPrefixOf(prefix: StorageKey, candidate: StorageKey): boolean { + return ( + prefix.length <= candidate.length && + prefix.every((segment, index) => segment === candidate[index]) + ); +} + +const port = process.argv[2] ? parseInt(process.argv[2]) : 8080; +const server = new Server(port); + +process.on("SIGINT", () => { + server.close(); + process.exit(0); +}); diff --git a/websocket/interop-test-server/tsconfig.json b/websocket/interop-test-server/tsconfig.json new file mode 100644 index 0000000..ccf34ba --- /dev/null +++ b/websocket/interop-test-server/tsconfig.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "lib": ["ES2022", "DOM"], + "allowJs": true, + "checkJs": false, + "outDir": "./", + "rootDir": "./", + "strict": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "downlevelIteration": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + "noImplicitAny": false, + "noImplicitThis": false, + "noImplicitReturns": false, + "noFallthroughCasesInSwitch": false, + "noUncheckedIndexedAccess": false, + "exactOptionalPropertyTypes": false, + "useUnknownInCatchVariables": false + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"], + "ts-node": { + "esm": true + } +} diff --git a/websocket/src/main/java/org/automerge/repo/websocket/WebSocketClientTransportConfig.java b/websocket/src/main/java/org/automerge/repo/websocket/WebSocketClientTransportConfig.java new file mode 100644 index 0000000..e8f2574 --- /dev/null +++ b/websocket/src/main/java/org/automerge/repo/websocket/WebSocketClientTransportConfig.java @@ -0,0 +1,127 @@ +package org.automerge.repo.websocket; + +import java.net.URI; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.net.ssl.SSLContext; + +/** + * Configuration for {@link WebSocketDialer} (a {@link org.automerge.repo.Dialer} implementation). + * + *

+ * Use the builder to construct instances: + * + *

{@code
+ * WebSocketClientTransportConfig config = WebSocketClientTransportConfig
+ *     .builder(URI.create("wss://sync.example.com"))
+ *     .header("Authorization", "Bearer token")
+ *     .connectTimeoutMs(5000)
+ *     .sslContext(mySSLContext)
+ *     .build();
+ * }
+ */ +public class WebSocketClientTransportConfig { + + private final URI serverUri; + private final int connectTimeoutMs; + private final Map headers; + private final SSLContext sslContext; + + private WebSocketClientTransportConfig(Builder builder) { + this.serverUri = builder.serverUri; + this.connectTimeoutMs = builder.connectTimeoutMs; + this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(builder.headers)); + this.sslContext = builder.sslContext; + } + + public URI getServerUri() { + return serverUri; + } + + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public Map getHeaders() { + return headers; + } + + public SSLContext getSslContext() { + return sslContext; + } + + /** + * Creates a new builder with the given server URI. + * + * @param serverUri + * the WebSocket server URI ({@code ws://} or {@code wss://}) + * @return a new builder + */ + public static Builder builder(URI serverUri) { + if (serverUri == null) { + throw new IllegalArgumentException("serverUri must not be null"); + } + return new Builder(serverUri); + } + + public static class Builder { + + private final URI serverUri; + private int connectTimeoutMs = 10000; + private final Map headers = new LinkedHashMap<>(); + private SSLContext sslContext; + + private Builder(URI serverUri) { + this.serverUri = serverUri; + } + + /** + * Sets the WebSocket handshake timeout in milliseconds. + * + * @param connectTimeoutMs + * timeout in milliseconds (default 10000) + * @return this builder + */ + public Builder connectTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + return this; + } + + /** + * Adds a custom HTTP header for the WebSocket upgrade request. + * + * @param name + * header name + * @param value + * header value + * @return this builder + */ + public Builder header(String name, String value) { + this.headers.put(name, value); + return this; + } + + /** + * Sets the SSL context for {@code wss://} connections. If null and the URI + * scheme is {@code wss://}, the JVM default SSL context is used. + * + * @param sslContext + * the SSL context, or null for JVM default + * @return this builder + */ + public Builder sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + /** + * Builds the configuration. + * + * @return the built config + */ + public WebSocketClientTransportConfig build() { + return new WebSocketClientTransportConfig(this); + } + } +} diff --git a/websocket/src/main/java/org/automerge/repo/websocket/WebSocketDialer.java b/websocket/src/main/java/org/automerge/repo/websocket/WebSocketDialer.java new file mode 100644 index 0000000..9c11fdc --- /dev/null +++ b/websocket/src/main/java/org/automerge/repo/websocket/WebSocketDialer.java @@ -0,0 +1,163 @@ +package org.automerge.repo.websocket; + +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import org.automerge.repo.Dialer; +import org.automerge.repo.Transport; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Dialer that creates WebSocket client transports. + * + *

+ * Usage with the Dialer/Listener API: + * + *

{@code
+ * // Simple - just a URL
+ * DialerHandle handle = repo.dial(
+ *     new WebSocketDialer(URI.create("ws://sync.example.com")));
+ *
+ * // With config
+ * WebSocketClientTransportConfig config = WebSocketClientTransportConfig
+ *     .builder(URI.create("wss://sync.example.com"))
+ *     .header("Authorization", "Bearer token")
+ *     .connectTimeoutMs(5000)
+ *     .build();
+ * DialerHandle handle = repo.dial(
+ *     new WebSocketDialer(config));
+ * }
+ */ +public class WebSocketDialer implements Dialer { + + private static final Logger logger = LoggerFactory.getLogger( + WebSocketDialer.class + ); + + private final WebSocketClientTransportConfig config; + + /** + * Creates a dialer with default configuration for the given URI. + * + * @param uri + * the WebSocket server URI ({@code ws://} or {@code wss://}) + */ + public WebSocketDialer(URI uri) { + this(WebSocketClientTransportConfig.builder(uri).build()); + } + + /** + * Creates a dialer with the given configuration. + * + * @param config + * the client transport configuration + */ + public WebSocketDialer(WebSocketClientTransportConfig config) { + if (config == null) { + throw new IllegalArgumentException("config must not be null"); + } + this.config = config; + } + + @Override + public String getUrl() { + return config.getServerUri().toString(); + } + + /** + * Initiates a non-blocking connection to the WebSocket server. The returned + * future completes when the WebSocket handshake succeeds, or fails if the + * connection cannot be established. + */ + @Override + public CompletableFuture connect() { + Map headers = config.getHeaders(); + CompletableFuture result = new CompletableFuture<>(); + + // Both the transport and ws client reference each other: + // - transport.send()/close() delegate to wsClient + // - wsClient callbacks push into transport.onMessage()/onClose() + // We break the circular dependency with a final array. + final WebSocketClient[] clientHolder = new WebSocketClient[1]; + + Transport transport = new Transport( + data -> clientHolder[0].send(data), + () -> clientHolder[0].close() + ); + + WebSocketClient wsClient = new WebSocketClient( + config.getServerUri(), + headers + ) { + @Override + public void onOpen(ServerHandshake handshake) { + result.complete(transport); + } + + @Override + public void onMessage(String message) { + logger.warn("Received unexpected text message, ignoring"); + } + + @Override + public void onMessage(ByteBuffer bytes) { + byte[] data = new byte[bytes.remaining()]; + bytes.get(data); + transport.onMessage(data); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + logger.debug( + "WebSocket client onClose: code={}, reason={}, remote={}", + code, + reason, + remote + ); + transport.onClose(); + result.completeExceptionally( + new RuntimeException( + "WebSocket connection closed (code=" + code + ")" + ) + ); + } + + @Override + public void onError(Exception ex) { + logger.debug("WebSocket client error", ex); + result.completeExceptionally(ex); + } + }; + clientHolder[0] = wsClient; + + wsClient.setConnectionLostTimeout(0); + + // Configure SSL if needed + String scheme = config.getServerUri().getScheme(); + if ("wss".equalsIgnoreCase(scheme)) { + try { + SSLContext sslContext = config.getSslContext(); + if (sslContext == null) { + sslContext = SSLContext.getDefault(); + } + SSLSocketFactory factory = sslContext.getSocketFactory(); + wsClient.setSocketFactory(factory); + } catch (Exception e) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally( + new RuntimeException("Failed to configure SSL", e) + ); + return failed; + } + } + + wsClient.connect(); + return result; + } +} diff --git a/websocket/src/main/java/org/automerge/repo/websocket/WebSocketServerTransport.java b/websocket/src/main/java/org/automerge/repo/websocket/WebSocketServerTransport.java new file mode 100644 index 0000000..9a23a2f --- /dev/null +++ b/websocket/src/main/java/org/automerge/repo/websocket/WebSocketServerTransport.java @@ -0,0 +1,148 @@ +package org.automerge.repo.websocket; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +import java.util.concurrent.ConcurrentHashMap; +import org.automerge.repo.AcceptorHandle; +import org.automerge.repo.Transport; +import org.java_websocket.WebSocket; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.server.WebSocketServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * WebSocket server that accepts incoming connections and bridges each to the + * {@link Transport} class. + * + *

+ * Usage with the Dialer/Listener API: + * + *

{@code
+ * AcceptorHandle acceptor = repo.makeAcceptor("ws://0.0.0.0:8080");
+ * WebSocketServerTransport server = new WebSocketServerTransport(
+ *     new InetSocketAddress(8080),
+ *     acceptor
+ * );
+ * server.start();
+ *
+ * // Later...
+ * server.stop();
+ * }
+ */ +public class WebSocketServerTransport { + + private static final Logger logger = LoggerFactory.getLogger( + WebSocketServerTransport.class + ); + + private final WebSocketServer server; + private final ConcurrentHashMap connections = + new ConcurrentHashMap<>(); + private final AcceptorHandle acceptorHandle; + + /** + * Creates a WebSocket server transport. + * + * @param address + * the address to bind to (use port 0 for a random available port) + * @param acceptorHandle + * the {@link AcceptorHandle} to which incoming connections are + * handed off via {@code acceptorHandle.accept(transport)} + */ + public WebSocketServerTransport( + InetSocketAddress address, + AcceptorHandle acceptorHandle + ) { + this.acceptorHandle = acceptorHandle; + this.server = new WebSocketServer(address) { + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + Transport transport = new Transport( + data -> conn.send(data), + () -> conn.close() + ); + connections.put(conn, transport); + acceptorHandle.accept(transport); + } + + @Override + public void onClose( + WebSocket conn, + int code, + String reason, + boolean remote + ) { + Transport transport = connections.remove(conn); + if (transport != null) { + transport.onClose(); + } + } + + @Override + public void onMessage(WebSocket conn, String message) { + logger.warn( + "Received unexpected text message from {}, ignoring", + conn.getRemoteSocketAddress() + ); + } + + @Override + public void onMessage(WebSocket conn, ByteBuffer message) { + Transport transport = connections.get(conn); + if (transport != null) { + byte[] data = new byte[message.remaining()]; + message.get(data); + transport.onMessage(data); + } + } + + @Override + public void onError(WebSocket conn, Exception ex) { + if (conn != null) { + logger.debug( + "WebSocket server error on connection {}", + conn.getRemoteSocketAddress(), + ex + ); + } else { + logger.debug("WebSocket server error", ex); + } + } + + @Override + public void onStart() { + logger.debug("WebSocket server started on port {}", getPort()); + } + }; + this.server.setReuseAddr(true); + } + + /** + * Starts the WebSocket server. + */ + public void start() { + server.start(); + } + + /** + * Stops the WebSocket server and closes all connections. + * + * @throws InterruptedException + * if interrupted while waiting for the server to stop + */ + public void stop() throws InterruptedException { + server.stop(); + acceptorHandle.close(); + } + + /** + * Returns the port the server is listening on. Useful when the server was + * started with port 0 to get the actual assigned port. + * + * @return the server port + */ + public int getPort() { + return server.getPort(); + } +} diff --git a/websocket/src/test/java/org/automerge/repo/websocket/WebSocketClientTransportTest.java b/websocket/src/test/java/org/automerge/repo/websocket/WebSocketClientTransportTest.java new file mode 100644 index 0000000..1a5ded2 --- /dev/null +++ b/websocket/src/test/java/org/automerge/repo/websocket/WebSocketClientTransportTest.java @@ -0,0 +1,287 @@ +package org.automerge.repo.websocket; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.ByteBuffer; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.automerge.repo.Transport; +import org.java_websocket.WebSocket; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ClientHandshake; +import org.java_websocket.handshake.ServerHandshake; +import org.java_websocket.server.WebSocketServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class WebSocketClientTransportTest { + + private TestServer server; + private int port; + + @BeforeEach + void setUp() throws Exception { + server = new TestServer(new InetSocketAddress(0)); + server.start(); + // Wait for server to start + assertTrue(server.startLatch.await(5, TimeUnit.SECONDS), "Server should start"); + port = server.getPort(); + } + + @AfterEach + void tearDown() throws Exception { + if (server != null) { + server.stop(); + } + } + + /** + * Creates a connected WebSocket client + Transport pair. This mirrors what + * {@link WebSocketDialer#connect()} does internally, but returns the + * transport for direct testing. + */ + private Transport connectTransport() throws InterruptedException { + final WebSocketClient[] clientHolder = new WebSocketClient[1]; + + Transport transport = new Transport( + data -> clientHolder[0].send(data), + () -> clientHolder[0].close()); + + WebSocketClient wsClient = new WebSocketClient( + URI.create("ws://localhost:" + port)) { + @Override + public void onOpen(ServerHandshake handshake) {} + + @Override + public void onMessage(String message) {} + + @Override + public void onMessage(ByteBuffer bytes) { + byte[] data = new byte[bytes.remaining()]; + bytes.get(data); + transport.onMessage(data); + } + + @Override + public void onClose(int code, String reason, boolean remote) { + transport.onClose(); + } + + @Override + public void onError(Exception ex) {} + }; + clientHolder[0] = wsClient; + wsClient.setConnectionLostTimeout(0); + + assertTrue(wsClient.connectBlocking(5, TimeUnit.SECONDS), "Should connect"); + return transport; + } + + @Test + void testConnectAndReceiveBinaryMessage() throws Exception { + CountDownLatch messageLatch = new CountDownLatch(1); + CopyOnWriteArrayList received = new CopyOnWriteArrayList<>(); + + Transport transport = connectTransport(); + transport.connect(msg -> { + received.add(msg); + messageLatch.countDown(); + }, () -> {}); + + try { + WebSocket conn = server.lastConnection; + assertNotNull(conn); + conn.send(new byte[]{1, 2, 3}); + + assertTrue(messageLatch.await(5, TimeUnit.SECONDS), "Should receive message"); + assertEquals(1, received.size()); + assertArrayEquals(new byte[]{1, 2, 3}, received.get(0)); + } finally { + transport.close(); + } + } + + @Test + void testSendAfterConnection() throws Exception { + CountDownLatch serverReceiveLatch = new CountDownLatch(1); + CopyOnWriteArrayList serverReceived = new CopyOnWriteArrayList<>(); + server.onMessageCallback = (conn, data) -> { + serverReceived.add(data); + serverReceiveLatch.countDown(); + }; + + Transport transport = connectTransport(); + transport.connect(msg -> {}, () -> {}); + + try { + CompletableFuture sendFuture = transport.send(new byte[]{40, 50, 60}); + sendFuture.get(5, TimeUnit.SECONDS); + + assertTrue(serverReceiveLatch.await(5, TimeUnit.SECONDS), "Server should receive message"); + assertArrayEquals(new byte[]{40, 50, 60}, serverReceived.get(0)); + } finally { + transport.close(); + } + } + + @Test + void testCloseCallsOnCloseExactlyOnce() throws Exception { + AtomicInteger closeCount = new AtomicInteger(0); + CountDownLatch closeLatch = new CountDownLatch(1); + + Transport transport = connectTransport(); + transport.connect(msg -> {}, () -> { + closeCount.incrementAndGet(); + closeLatch.countDown(); + }); + + transport.close(); + + assertTrue(closeLatch.await(5, TimeUnit.SECONDS), "onClose should be called"); + Thread.sleep(200); // Wait to see if called again + assertEquals(1, closeCount.get(), "onClose should be called exactly once"); + } + + @Test + void testCloseIsIdempotent() throws Exception { + AtomicInteger closeCount = new AtomicInteger(0); + + Transport transport = connectTransport(); + transport.connect(msg -> {}, () -> { + closeCount.incrementAndGet(); + }); + + transport.close(); + transport.close(); + transport.close(); + + Thread.sleep(200); + assertEquals(1, closeCount.get(), "onClose should be called exactly once despite multiple close() calls"); + } + + @Test + void testSendAfterCloseFails() throws Exception { + Transport transport = connectTransport(); + transport.connect(msg -> {}, () -> {}); + + transport.close(); + + CompletableFuture sendFuture = transport.send(new byte[]{1}); + assertTrue(sendFuture.isCompletedExceptionally(), "Send after close should fail"); + } + + @Test + void testDialerConnectFailsForBadPort() throws Exception { + WebSocketDialer dialer = new WebSocketDialer( + WebSocketClientTransportConfig.builder(URI.create("ws://localhost:1")).build()); + CompletableFuture result = dialer.connect(); + try { + result.get(5, TimeUnit.SECONDS); + fail("Connection to bad port should fail"); + } catch (java.util.concurrent.ExecutionException e) { + // expected + } + } + + @Test + void testMultipleMessagesArriveInOrder() throws Exception { + int messageCount = 10; + CountDownLatch messageLatch = new CountDownLatch(messageCount); + CopyOnWriteArrayList received = new CopyOnWriteArrayList<>(); + + Transport transport = connectTransport(); + transport.connect(msg -> { + received.add(msg); + messageLatch.countDown(); + }, () -> {}); + + try { + WebSocket conn = server.lastConnection; + for (int i = 0; i < messageCount; i++) { + conn.send(new byte[]{(byte) i}); + } + + assertTrue(messageLatch.await(5, TimeUnit.SECONDS), "Should receive all messages"); + assertEquals(messageCount, received.size()); + for (int i = 0; i < messageCount; i++) { + assertArrayEquals(new byte[]{(byte) i}, received.get(i), "Message " + i + " should be in order"); + } + } finally { + transport.close(); + } + } + + @Test + void testRemoteCloseTriggersOnClose() throws Exception { + CountDownLatch closeLatch = new CountDownLatch(1); + + Transport transport = connectTransport(); + transport.connect(msg -> {}, () -> { + closeLatch.countDown(); + }); + + try { + // Server closes the connection + server.lastConnection.close(); + + assertTrue(closeLatch.await(5, TimeUnit.SECONDS), "onClose should be called on remote close"); + } finally { + transport.close(); + } + } + + /** + * Minimal WebSocket server for testing. + */ + static class TestServer extends WebSocketServer { + + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch connectionLatch = new CountDownLatch(1); + volatile WebSocket lastConnection; + volatile MessageCallback onMessageCallback; + + interface MessageCallback { + void onMessage(WebSocket conn, byte[] data); + } + + TestServer(InetSocketAddress address) { + super(address); + setReuseAddr(true); + } + + @Override + public void onOpen(WebSocket conn, ClientHandshake handshake) { + lastConnection = conn; + connectionLatch.countDown(); + } + + @Override + public void onClose(WebSocket conn, int code, String reason, boolean remote) {} + + @Override + public void onMessage(WebSocket conn, String message) {} + + @Override + public void onMessage(WebSocket conn, ByteBuffer message) { + if (onMessageCallback != null) { + byte[] data = new byte[message.remaining()]; + message.get(data); + onMessageCallback.onMessage(conn, data); + } + } + + @Override + public void onError(WebSocket conn, Exception ex) {} + + @Override + public void onStart() { + startLatch.countDown(); + } + } +} diff --git a/websocket/src/test/java/org/automerge/repo/websocket/WebSocketIntegrationTest.java b/websocket/src/test/java/org/automerge/repo/websocket/WebSocketIntegrationTest.java new file mode 100644 index 0000000..06353ac --- /dev/null +++ b/websocket/src/test/java/org/automerge/repo/websocket/WebSocketIntegrationTest.java @@ -0,0 +1,298 @@ +package org.automerge.repo.websocket; + +import static org.junit.jupiter.api.Assertions.*; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; +import org.automerge.AmValue; +import org.automerge.ObjectId; +import org.automerge.Transaction; +import org.automerge.repo.AcceptorHandle; +import org.automerge.repo.DialerHandle; +import org.automerge.repo.DocHandle; +import org.automerge.repo.DocumentId; +import org.automerge.repo.PeerId; +import org.automerge.repo.Repo; +import org.automerge.repo.RepoConfig; +import org.automerge.repo.storage.InMemoryStorage; +import org.junit.jupiter.api.Test; + +/** + * Integration tests using both WebSocket client and server transports together + * with real Repo instances. + * + *

+ * These tests require a working native library (automerge-jni). They will fail + * in environments where the JNI hub event loop cannot function properly (e.g., + * sandboxed environments with restricted threading). + */ +class WebSocketIntegrationTest { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration POLL_INTERVAL = Duration.ofMillis(50); + + @Test + void testTwoReposSyncOverWebSocket() throws Exception { + Repo serverRepo = Repo.load(RepoConfig.builder().peerId(PeerId.fromString("server")).build()); + + AcceptorHandle acceptor = serverRepo.makeAcceptor("ws://0.0.0.0:0"); + WebSocketServerTransport wsServer = new WebSocketServerTransport(new InetSocketAddress(0), acceptor); + wsServer.start(); + Thread.sleep(200); + int port = wsServer.getPort(); + + Repo clientRepo = Repo.load(RepoConfig.builder().storage(new InMemoryStorage()).peerId(PeerId.fromString("client")).build()); + + try { + // Connect client to server via Dialer + DialerHandle dialerHandle = clientRepo.dial( + new WebSocketDialer(URI.create("ws://localhost:" + port))); + waitFor(dialerHandle.onEstablished(), "client connection established"); + + // Create document on client + DocHandle clientHandle = waitFor(clientRepo.create(), "create document on client"); + DocumentId docId = clientHandle.getDocumentId(); + + waitFor(clientHandle.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "source", "client"); + tx.commit(); + } + return null; + }), "set content on client"); + + // Wait for document to appear on server + eventually(() -> { + try { + return serverRepo.find(docId).get() != null; + } catch (Exception e) { + return false; + } + }).succeeds("document appears on server"); + + // Verify content on server + DocHandle serverHandle = waitFor(serverRepo.find(docId), "find document on server").get(); + String value = waitFor(serverHandle.withDocument(doc -> { + return doc.get(ObjectId.ROOT, "source").map(v -> v instanceof AmValue.Str ? ((AmValue.Str) v).getValue() : null).orElse(null); + }), "read value from server"); + + assertEquals("client", value, "Document should sync from client to server"); + + dialerHandle.close(); + } finally { + acceptor.close(); + clientRepo.close(); + serverRepo.close(); + wsServer.stop(); + } + } + + @Test + void testBidirectionalSync() throws Exception { + Repo serverRepo = Repo.load(RepoConfig.builder().storage(new InMemoryStorage()).peerId(PeerId.fromString("server")).build()); + + AcceptorHandle acceptor = serverRepo.makeAcceptor("ws://0.0.0.0:0"); + WebSocketServerTransport wsServer = new WebSocketServerTransport(new InetSocketAddress(0), acceptor); + wsServer.start(); + Thread.sleep(200); + int port = wsServer.getPort(); + + Repo clientRepo = Repo.load(RepoConfig.builder().storage(new InMemoryStorage()).peerId(PeerId.fromString("client")).build()); + + try { + DialerHandle dialerHandle = clientRepo.dial( + new WebSocketDialer(URI.create("ws://localhost:" + port))); + waitFor(dialerHandle.onEstablished(), "connection established"); + + // Create document on server + DocHandle serverHandle = waitFor(serverRepo.create(), "create document on server"); + DocumentId docId = serverHandle.getDocumentId(); + + waitFor(serverHandle.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "server-key", "server-value"); + tx.commit(); + } + return null; + }), "set content on server"); + + // Wait for document to appear on client + eventually(() -> { + try { + return clientRepo.find(docId).get() != null; + } catch (Exception e) { + return false; + } + }).succeeds("document appears on client"); + + DocHandle clientHandle = waitFor(clientRepo.find(docId), "find document on client").get(); + + // Verify server content arrived + String serverValue = waitFor(clientHandle.withDocument(doc -> { + return doc.get(ObjectId.ROOT, "server-key").map(v -> v instanceof AmValue.Str ? ((AmValue.Str) v).getValue() : null).orElse(null); + }), "read server value from client"); + assertEquals("server-value", serverValue); + + // Now modify on client side + waitFor(clientHandle.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "client-key", "client-value"); + tx.commit(); + } + return null; + }), "set content on client"); + + // Wait for client change to sync to server + eventually(() -> { + try { + String val = serverHandle.withDocument(doc -> { + return doc.get(ObjectId.ROOT, "client-key").map(v -> v instanceof AmValue.Str ? ((AmValue.Str) v).getValue() : null).orElse(null); + }).get(); + return "client-value".equals(val); + } catch (Exception e) { + return false; + } + }).succeeds("client change syncs to server"); + + dialerHandle.close(); + } finally { + acceptor.close(); + clientRepo.close(); + serverRepo.close(); + wsServer.stop(); + } + } + + @Test + void testDisconnectAndReconnect() throws Exception { + Repo serverRepo = Repo.load(RepoConfig.builder().storage(new InMemoryStorage()).peerId(PeerId.fromString("server")).build()); + + AcceptorHandle acceptor = serverRepo.makeAcceptor("ws://0.0.0.0:0"); + WebSocketServerTransport wsServer = new WebSocketServerTransport(new InetSocketAddress(0), acceptor); + wsServer.start(); + Thread.sleep(200); + int port = wsServer.getPort(); + + Repo clientRepo = Repo.load(RepoConfig.builder().storage(new InMemoryStorage()).peerId(PeerId.fromString("client")).build()); + + try { + // First connection: create and sync a document + DialerHandle dialer1 = clientRepo.dial( + new WebSocketDialer(URI.create("ws://localhost:" + port))); + waitFor(dialer1.onEstablished(), "first connection established"); + + DocHandle clientHandle = waitFor(clientRepo.create(), "create document"); + DocumentId docId = clientHandle.getDocumentId(); + + waitFor(clientHandle.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "phase", "initial"); + tx.commit(); + } + return null; + }), "set initial content"); + + eventually(() -> { + try { + return serverRepo.find(docId).get() != null; + } catch (Exception e) { + return false; + } + }).succeeds("document synced to server"); + + // Disconnect + dialer1.close(); + + // Modify while disconnected + waitFor(clientHandle.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "phase", "after-disconnect"); + tx.commit(); + } + return null; + }), "modify while disconnected"); + + // Reconnect with a new dialer + DialerHandle dialer2 = clientRepo.dial( + new WebSocketDialer(URI.create("ws://localhost:" + port))); + waitFor(dialer2.onEstablished(), "second connection established"); + + // Wait for disconnected change to sync + DocHandle serverHandle = waitFor(serverRepo.find(docId), "find document on server").get(); + eventually(() -> { + try { + String val = serverHandle.withDocument(doc -> { + return doc.get(ObjectId.ROOT, "phase").map(v -> v instanceof AmValue.Str ? ((AmValue.Str) v).getValue() : null).orElse(null); + }).get(); + return "after-disconnect".equals(val); + } catch (Exception e) { + return false; + } + }).succeeds("disconnected change syncs after reconnect"); + + dialer2.close(); + } finally { + acceptor.close(); + clientRepo.close(); + serverRepo.close(); + wsServer.stop(); + } + } + + // -- Helpers -- + + private static T waitFor(CompletableFuture future, String description) { + try { + return future.get(DEFAULT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + throw new AssertionError(String.format("Timeout waiting for: %s (waited %dms)", description, DEFAULT_TIMEOUT.toMillis()), e); + } catch (ExecutionException e) { + throw new AssertionError(String.format("Future completed exceptionally while waiting for: %s", description), e.getCause()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(String.format("Interrupted while waiting for: %s", description), e); + } + } + + private static EventuallyAssertion eventually(Supplier condition) { + return new EventuallyAssertion(condition); + } + + static class EventuallyAssertion { + + private final Supplier condition; + private Duration timeout = DEFAULT_TIMEOUT; + + EventuallyAssertion(Supplier condition) { + this.condition = condition; + } + + EventuallyAssertion timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + void succeeds(String description) { + long startTime = System.currentTimeMillis(); + long timeoutMs = timeout.toMillis(); + while (System.currentTimeMillis() - startTime < timeoutMs) { + if (condition.get()) { + return; + } + try { + Thread.sleep(POLL_INTERVAL.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for: " + description, e); + } + } + throw new AssertionError(String.format("Condition not met within timeout: %s (waited %dms)", description, timeoutMs)); + } + } +} diff --git a/websocket/src/test/java/org/automerge/repo/websocket/interop/JsServerWrapper.java b/websocket/src/test/java/org/automerge/repo/websocket/interop/JsServerWrapper.java new file mode 100644 index 0000000..4ae38f6 --- /dev/null +++ b/websocket/src/test/java/org/automerge/repo/websocket/interop/JsServerWrapper.java @@ -0,0 +1,341 @@ +package org.automerge.repo.websocket.interop; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Manages the lifecycle of the JavaScript interop test server. + * + *

+ * On first use, runs {@code npm install} and {@code npm run build} in the + * {@code websocket/interop-test-server} directory (found relative to the + * Gradle working directory). Subsequent calls skip the build step. + * + *

+ * Call {@link #start()} to spawn {@code node server.js 0} and read the port it + * binds to. Call {@link #close()} (or use try-with-resources) to shut it down. + */ +public class JsServerWrapper implements AutoCloseable { + + /** Cached build result: null = not yet run, non-null = done or failed. */ + private static final AtomicReference buildError = new AtomicReference<>(null); + private static volatile boolean buildDone = false; + + private final File serverDir; + private Process serverProcess; + private int port; + + public JsServerWrapper(File serverDir) { + this.serverDir = serverDir; + } + + /** + * Returns the {@code interop-test-server} directory. Looks for + * {@code websocket/interop-test-server} relative to the process working + * directory (the Gradle project root when tests run), or uses the + * {@code interop.test.server.dir} system property if set. + * + * @return the directory, or {@code null} if not found + */ + public static File findServerDir() { + String prop = System.getProperty("interop.test.server.dir"); + if (prop != null) { + File dir = new File(prop); + if (dir.isDirectory()) + return dir; + } + // When Gradle runs :websocket tests, working dir is the websocket/ subproject. + File[] candidates = { + new File("interop-test-server"), + new File("websocket/interop-test-server"), + }; + for (File c : candidates) { + if (c.isDirectory() && new File(c, "package.json").exists()) + return c.getAbsoluteFile(); + } + return null; + } + + /** + * Ensures the JS project is built (npm install + npm run build), then spawns + * {@code node server.js 0} and returns a ready wrapper. + * + * @throws Exception + * if the build fails or the server does not start + */ + public static JsServerWrapper start(File serverDir) throws Exception { + ensureBuilt(serverDir); + JsServerWrapper w = new JsServerWrapper(serverDir); + w.startServer(); + return w; + } + + // -- Build -- + + private static synchronized void ensureBuilt(File serverDir) throws Exception { + if (buildDone) + return; + + runCommand(serverDir, "npm", "install"); + runCommand(serverDir, "npm", "run", "build"); + + buildDone = true; + } + + private static void runCommand(File dir, String... cmd) throws Exception { + ProcessBuilder pb = new ProcessBuilder(Arrays.asList(cmd)); + pb.directory(dir); + pb.redirectErrorStream(true); // merge stderr into stdout + Process proc = pb.start(); + + // Drain output, printing it for debugging + Thread reader = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + System.out.println("[interop-build] " + line); + } + } catch (IOException e) { + // process ended + } + }, "interop-build-output"); + reader.setDaemon(true); + reader.start(); + + if (!proc.waitFor(120, TimeUnit.SECONDS)) { + proc.destroyForcibly(); + throw new RuntimeException(String.format("Command timed out: %s", Arrays.toString(cmd))); + } + int exit = proc.exitValue(); + if (exit != 0) { + throw new RuntimeException(String.format("Command failed (exit %d): %s", exit, Arrays.toString(cmd))); + } + } + + // -- Server lifecycle -- + + private void startServer() throws Exception { + ProcessBuilder pb = new ProcessBuilder("node", "server.js", "0"); + pb.directory(serverDir); + serverProcess = pb.start(); + + BlockingQueue stdoutLines = new ArrayBlockingQueue<>(256); + + Thread stdoutThread = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(serverProcess.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + stdoutLines.offer(line); + } + } catch (IOException e) { + // process ended + } + }, "js-server-stdout"); + stdoutThread.setDaemon(true); + stdoutThread.start(); + + Thread stderrThread = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(serverProcess.getErrorStream()))) { + String line; + while ((line = r.readLine()) != null) { + System.err.println("[JS server] " + line); + } + } catch (IOException e) { + // process ended + } + }, "js-server-stderr"); + stderrThread.setDaemon(true); + stderrThread.start(); + + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + String line = stdoutLines.poll(100, TimeUnit.MILLISECONDS); + if (line == null) + continue; + if (line.startsWith("Listening on port ")) { + port = Integer.parseInt(line.substring("Listening on port ".length()).trim()); + return; + } + } + serverProcess.destroyForcibly(); + throw new RuntimeException("JS server did not print 'Listening on port N' within 10s"); + } + + public int getPort() { + return port; + } + + // -- Client -- + + /** + * Runs {@code node client.js }, collects stdout lines, then kills the + * process. + * + *

+ * The JS client scripts (create, fetch) do not call {@code process.exit()} — + * they keep the WebSocket connection open after printing their output. We + * therefore read lines until there has been no new output for + * {@code quietMs} milliseconds, then destroy the process. + */ + public List runClient(String... args) throws Exception { + return runClient(2000, args); + } + + public List runClient(long quietMs, String... args) throws Exception { + List cmd = new ArrayList<>(); + cmd.add("node"); + cmd.add("client.js"); + for (String arg : args) { + cmd.add(arg); + } + + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(serverDir); + Process proc = pb.start(); + + BlockingQueue queue = new ArrayBlockingQueue<>(256); + + Thread stderrThread = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getErrorStream()))) { + String line; + while ((line = r.readLine()) != null) { + System.err.println("[JS client] " + line); + } + } catch (IOException e) { + // process ended + } + }, "js-client-stderr"); + stderrThread.setDaemon(true); + stderrThread.start(); + + Thread stdoutThread = new Thread(() -> { + try (BufferedReader r = new BufferedReader(new InputStreamReader(proc.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + queue.offer(line); + } + } catch (IOException e) { + // process ended + } + }, "js-client-stdout"); + stdoutThread.setDaemon(true); + stdoutThread.start(); + + // Collect lines until quietMs have passed with no new output (indicating + // the client has printed everything it will print) or total 15s timeout. + List output = new ArrayList<>(); + long totalDeadline = System.currentTimeMillis() + 15_000; + long lastOutputTime = System.currentTimeMillis(); + while (System.currentTimeMillis() < totalDeadline) { + String line = queue.poll(100, TimeUnit.MILLISECONDS); + if (line != null) { + output.add(line); + lastOutputTime = System.currentTimeMillis(); + } else if (System.currentTimeMillis() - lastOutputTime >= quietMs) { + break; // quiet period elapsed — client has printed everything + } + } + + proc.destroyForcibly(); + proc.waitFor(5, TimeUnit.SECONDS); + + if (output.isEmpty()) { + throw new RuntimeException("JS client produced no output. Args: " + Arrays.toString(args)); + } + return output; + } + + // -- Storage keys -- + + /** + * Fetches {@code /storage-keys} and returns each key as a list of path + * segments (e.g. {@code [["docId","snapshot","hash"], ...]}). + */ + public List> storageKeys() throws Exception { + URL url = new URL("http://localhost:" + port + "/storage-keys"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + StringBuilder sb = new StringBuilder(); + try (BufferedReader r = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = r.readLine()) != null) { + sb.append(line); + } + } + return parseJsonArrayOfArrays(sb.toString().trim()); + } + + // -- minimal JSON parser for array of string arrays -- + + private static List> parseJsonArrayOfArrays(String json) { + List> result = new ArrayList<>(); + if (json.equals("[]")) + return result; + json = json.substring(1, json.length() - 1).trim(); + int i = 0; + while (i < json.length()) { + if (json.charAt(i) == '[') { + int end = findClosingBracket(json, i); + result.add(parseStringArray(json.substring(i + 1, end))); + i = end + 1; + while (i < json.length() && (json.charAt(i) == ',' || json.charAt(i) == ' ')) + i++; + } else { + i++; + } + } + return result; + } + + private static int findClosingBracket(String s, int open) { + int depth = 0; + for (int i = open; i < s.length(); i++) { + if (s.charAt(i) == '[') + depth++; + else if (s.charAt(i) == ']') { + depth--; + if (depth == 0) + return i; + } + } + throw new IllegalArgumentException("No closing bracket in: " + s.substring(open)); + } + + private static List parseStringArray(String inner) { + List result = new ArrayList<>(); + boolean inString = false; + int start = -1; + for (int i = 0; i < inner.length(); i++) { + char c = inner.charAt(i); + if (c == '"' && !inString) { + inString = true; + start = i + 1; + } else if (c == '"' && inString) { + result.add(inner.substring(start, i)); + inString = false; + } + } + return result; + } + + @Override + public void close() { + if (serverProcess != null && serverProcess.isAlive()) { + serverProcess.destroyForcibly(); + } + } +} diff --git a/websocket/src/test/java/org/automerge/repo/websocket/interop/WebSocketInteropTest.java b/websocket/src/test/java/org/automerge/repo/websocket/interop/WebSocketInteropTest.java new file mode 100644 index 0000000..3533468 --- /dev/null +++ b/websocket/src/test/java/org/automerge/repo/websocket/interop/WebSocketInteropTest.java @@ -0,0 +1,290 @@ +package org.automerge.repo.websocket.interop; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.net.InetSocketAddress; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; +import org.automerge.AmValue; +import org.automerge.ObjectId; +import org.automerge.Transaction; +import org.automerge.repo.AcceptorHandle; +import org.automerge.repo.DialerHandle; +import org.automerge.repo.DocHandle; +import org.automerge.repo.DocumentId; +import org.automerge.repo.PeerId; +import org.automerge.repo.Repo; +import org.automerge.repo.RepoConfig; +import org.automerge.repo.storage.InMemoryStorage; +import org.automerge.repo.websocket.WebSocketDialer; +import org.automerge.repo.websocket.WebSocketServerTransport; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Interop tests that spin up the JavaScript automerge-repo sync server and + * verify that Java repos can sync documents with it over WebSocket. + * + *

+ * Requires {@code node} to be on the PATH. The interop-test-server sources live + * in {@code websocket/interop-test-server/} and are built automatically on + * first run via {@code npm install} + {@code npm run build}. + * + *

+ * Also requires the native automerge JNI library to be loadable. + */ +class WebSocketInteropTest { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(15); + private static final Duration POLL_INTERVAL = Duration.ofMillis(100); + + private static File interopServerDir; + + @BeforeAll + static void findInteropServer() { + interopServerDir = JsServerWrapper.findServerDir(); + assertNotNull(interopServerDir, + "interop-test-server directory not found. Expected websocket/interop-test-server/ relative to working directory."); + } + + // -- Tests -- + + /** + * Two Java repos each connect to the JS server and sync a document through + * it. Mirrors the {@code sync_rust_clients_via_js_server} test in samod. + */ + @Test + void twoJavaReposSyncViaJsServer() throws Exception { + try (JsServerWrapper js = JsServerWrapper.start(interopServerDir)) { + int port = js.getPort(); + + Repo repo1 = Repo.load(RepoConfig.builder() + .storage(new InMemoryStorage()) + .peerId(PeerId.fromString("java-repo1")) + .build()); + + Repo repo2 = Repo.load(RepoConfig.builder() + .storage(new InMemoryStorage()) + .peerId(PeerId.fromString("java-repo2")) + .build()); + + try { + // Both repos connect to the JS server + DialerHandle dialer1 = connectToJsServer(repo1, port); + DialerHandle dialer2 = connectToJsServer(repo2, port); + + waitFor(dialer1.onEstablished(), "repo1 connection established"); + waitFor(dialer2.onEstablished(), "repo2 connection established"); + + // Create document on repo1 + DocHandle docHandle1 = waitFor(repo1.create(), "create document on repo1"); + DocumentId docId = docHandle1.getDocumentId(); + + waitFor(docHandle1.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "source", "java-repo1"); + tx.commit(); + } + return null; + }), "set content on repo1"); + + // Wait for document to appear on repo2 (via JS server relay) + eventually(() -> { + try { + return repo2.find(docId).get() != null; + } catch (Exception e) { + return false; + } + }).succeeds("document synced to repo2 via JS server"); + + // Verify content arrived correctly + DocHandle docHandle2 = waitFor(repo2.find(docId), "find document on repo2").get(); + String value = waitFor(docHandle2.withDocument(doc -> { + return doc.get(ObjectId.ROOT, "source") + .map(v -> v instanceof AmValue.Str ? ((AmValue.Str) v).getValue() : null) + .orElse(null); + }), "read value from repo2"); + + assertEquals("java-repo1", value, "Document content should have synced from repo1 to repo2 via JS server"); + + dialer1.close(); + dialer2.close(); + } finally { + repo1.close(); + repo2.close(); + } + } + } + + /** + * JS clients create and fetch a document through the Java WebSocket server. + * Mirrors the {@code two_js_clients_can_sync_through_rust_server} test in samod. + */ + @Test + void jsTwoClientsSyncThroughJavaServer() throws Exception { + Repo serverRepo = Repo.load(RepoConfig.builder() + .storage(new InMemoryStorage()) + .peerId(PeerId.fromString("java-server")) + .build()); + + AcceptorHandle acceptor = serverRepo.makeAcceptor("ws://0.0.0.0:0"); + WebSocketServerTransport wsServer = new WebSocketServerTransport( + new InetSocketAddress(0), + acceptor); + wsServer.start(); + Thread.sleep(200); + int port = wsServer.getPort(); + + try { + // JS client creates a document and syncs it to the Java server + List createOutput = waitForClient(interopServerDir, "create", String.valueOf(port)); + // Output: line 0 = automerge URL, line 1 = comma-separated heads + assertTrue(createOutput.size() >= 2, + "Expected at least 2 output lines from 'create', got: " + createOutput); + String docUrl = createOutput.get(0).trim(); + String originalHeads = createOutput.get(1).trim(); + + assertTrue(docUrl.startsWith("automerge:"), + "Expected automerge URL, got: " + docUrl); + assertFalse(originalHeads.isEmpty(), "Heads should not be empty"); + + // Another JS client fetches the same document from the Java server + List fetchOutput = waitForClient(interopServerDir, "fetch", String.valueOf(port), docUrl); + assertTrue(fetchOutput.size() >= 1, + "Expected at least 1 output line from 'fetch', got: " + fetchOutput); + String fetchedHeads = fetchOutput.get(0).trim(); + + assertEquals(originalHeads, fetchedHeads, + "Fetched heads should match original heads - document should have synced through Java server"); + } finally { + acceptor.close(); + serverRepo.close(); + wsServer.stop(); + } + } + + /** + * Verifies that the JS server saves sync state for a non-ephemeral Java repo. + * Mirrors {@code js_server_saves_sync_state_for_non_ephemeral_samod_peer} in samod. + */ + @Test + void jsSavedSyncStateForJavaRepo() throws Exception { + try (JsServerWrapper js = JsServerWrapper.start(interopServerDir)) { + int port = js.getPort(); + + Repo repo = Repo.load(RepoConfig.builder() + .storage(new InMemoryStorage()) + .peerId(PeerId.fromString("java-storage-repo")) + .build()); + + try { + DialerHandle dialerHandle = connectToJsServer(repo, port); + waitFor(dialerHandle.onEstablished(), "connection established"); + + DocHandle docHandle = waitFor(repo.create(), "create document"); + waitFor(docHandle.withDocument(doc -> { + try (Transaction tx = doc.startTransaction()) { + tx.set(ObjectId.ROOT, "key", "value"); + tx.commit(); + } + return null; + }), "set content"); + + // Wait for sync and storage to complete + Thread.sleep(2000); + + List> keys = js.storageKeys(); + System.out.println("JS server storage keys: " + keys); + + // The JS server should have saved sync state for the Java peer. + // Sync state keys have the form [documentId, "sync-state", storageId]. + boolean hasSyncState = keys.stream() + .anyMatch(key -> key.size() >= 2 && "sync-state".equals(key.get(1))); + + assertTrue(hasSyncState, + "JS server should have saved sync state for the Java peer, but storage keys were: " + keys); + + dialerHandle.close(); + } finally { + repo.close(); + } + } + } + + // -- Helpers -- + + private static DialerHandle connectToJsServer(Repo repo, int port) { + return repo.dial( + new WebSocketDialer(URI.create("ws://localhost:" + port))); + } + + /** + * Runs the JS client script and returns its stdout lines. Wraps the + * InterruptedException/IOException as RuntimeException for test convenience. + */ + private static List waitForClient(File serverDir, String... args) { + try { + JsServerWrapper clientRunner = new JsServerWrapper(serverDir); + return clientRunner.runClient(args); + } catch (Exception e) { + throw new AssertionError("JS client failed: " + e.getMessage(), e); + } + } + + private static T waitFor(CompletableFuture future, String description) { + try { + return future.get(DEFAULT_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + throw new AssertionError( + String.format("Timeout waiting for: %s (waited %dms)", description, DEFAULT_TIMEOUT.toMillis()), e); + } catch (ExecutionException e) { + throw new AssertionError( + String.format("Future completed exceptionally while waiting for: %s", description), e.getCause()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(String.format("Interrupted while waiting for: %s", description), e); + } + } + + private static EventuallyAssertion eventually(Supplier condition) { + return new EventuallyAssertion(condition); + } + + static class EventuallyAssertion { + + private final Supplier condition; + private Duration timeout = DEFAULT_TIMEOUT; + + EventuallyAssertion(Supplier condition) { + this.condition = condition; + } + + EventuallyAssertion timeout(Duration t) { + this.timeout = t; + return this; + } + + void succeeds(String description) { + long startTime = System.currentTimeMillis(); + long timeoutMs = timeout.toMillis(); + while (System.currentTimeMillis() - startTime < timeoutMs) { + if (condition.get()) + return; + try { + Thread.sleep(POLL_INTERVAL.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for: " + description, e); + } + } + throw new AssertionError(String.format("Condition not met within %dms: %s", timeoutMs, description)); + } + } +}