diff --git a/.env.example b/.env.example index f0b4776..2de239d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ -# chinook database is used in non-destructive tests +# chinook database used in non-destructive tests CHINOOK_DATABASE_URL="sqlitecloud://user:password@xxx.sqlite.cloud:8860/chinook.db" -# testing databases are also created automatically as testing-xxx.db and used in destructive tests -TESTING_DATABASE_URL="sqlitecloud://user:password@xxx.sqlite.cloud:8860/testing.db" \ No newline at end of file +# testing databases are created automatically as testing-xxx.db and used in destructive tests +TESTING_DATABASE_URL="sqlitecloud://user:password@xxx.sqlite.cloud:8860/testing.db" + +# sqlite cloud gateway for socket.io connections +GATEWAY_URL=ws://localhost:4000 diff --git a/package-lock.json b/package-lock.json index eca9b35..b7302b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { - "name": "sqlitecloud-js", - "version": "0.0.25", + "name": "sqlitecloud", + "version": "0.0.30", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "sqlitecloud-js", - "version": "0.0.25", + "name": "sqlitecloud", + "version": "0.0.30", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.1", - "lz4js": "^0.2.0" + "lz4js": "^0.2.0", + "socket.io-client": "^4.7.4" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -30,7 +31,9 @@ "ts-node": "^10.9.2", "typedoc": "^0.25.3", "typedoc-plugin-markdown": "^3.17.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "webpack": "^5.90.0", + "webpack-cli": "^5.1.4" }, "engines": { "node": ">=18.0" @@ -906,6 +909,15 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/@eslint/eslintrc": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", @@ -1306,6 +1318,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -1451,6 +1473,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1526,6 +1553,32 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/eslint": { + "version": "8.56.2", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", + "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1768,6 +1821,208 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", + "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", + "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", + "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", + "dev": true, + "engines": { + "node": ">=14.15.0" + }, + "peerDependencies": { + "webpack": "5.x.x", + "webpack-cli": "5.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1859,6 +2114,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2315,6 +2579,15 @@ "node": ">=10" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", + "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -2406,6 +2679,20 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2449,6 +2736,18 @@ "color-support": "bin.js" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2512,7 +2811,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -2660,6 +2958,39 @@ "iconv-lite": "^0.6.2" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", + "integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -2683,6 +3014,18 @@ "node": ">=6" } }, + "node_modules/envinfo": { + "version": "7.11.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.11.0.tgz", + "integrity": "sha512-G9/6xF1FPbIw0TtalAMaVPpiq2aDEuKLXM314jPVAO9r2fo2a4BLqMNkmRS7O/xPPZ+COAhGIz3ETvHEV3eUcg==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", @@ -2699,6 +3042,12 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-module-lexer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz", + "integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==", + "dev": true + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -3080,6 +3429,15 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3168,6 +3526,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -3223,6 +3590,15 @@ "node": ">=8" } }, + "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, + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", @@ -3410,6 +3786,12 @@ "node": ">= 6" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "node_modules/globals": { "version": "13.23.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz", @@ -3658,6 +4040,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -3729,6 +4120,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3747,6 +4150,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.1.tgz", @@ -4534,6 +4946,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -4571,6 +4992,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -4739,6 +5169,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4891,8 +5342,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5487,6 +5937,15 @@ } ] }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -5507,6 +5966,18 @@ "node": ">= 6" } }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -5678,6 +6149,24 @@ "dev": true, "optional": true }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -5711,12 +6200,33 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "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, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5782,6 +6292,32 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io-client": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.4.tgz", + "integrity": "sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/socks": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", @@ -6066,6 +6602,15 @@ "node": ">=8" } }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tar": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", @@ -6098,6 +6643,109 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/terser": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.27.0.tgz", + "integrity": "sha512-bi1HRwVRskAjheeYl291n3JC4GgO/Ty4z1nVs5AAsmonJulGxpSektecnNedrwK9C7vpvVtcX3cw00VSLt7U2A==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/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, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/terser/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, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -6498,12 +7146,170 @@ "makeerror": "1.0.12" } }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "node_modules/webpack": { + "version": "5.90.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.90.0.tgz", + "integrity": "sha512-bdmyXRCXeeNIePv6R6tGPyy20aUobw4Zy8r0LUS2EWO+U+Ke/gYDgsCh7bl5rB6jPpr4r0SZa6dPxBxLooDT3w==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.21.10", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", + "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^2.1.1", + "@webpack-cli/info": "^2.0.2", + "@webpack-cli/serve": "^2.0.5", + "colorette": "^2.0.14", + "commander": "^10.0.1", + "cross-spawn": "^7.0.3", + "envinfo": "^7.7.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/webpack-merge": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack/node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -6567,6 +7373,12 @@ "node": ">=8" } }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", @@ -6592,6 +7404,34 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "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/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 3ef2bc3..6a579c0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sqlitecloud-js", - "version": "0.0.29", + "version": "0.0.33", "description": "SQLiteCloud drivers for Typescript/Javascript in edge, web and node clients", "main": "./lib/index.js", "types": "./lib/index.d.ts", @@ -9,7 +9,7 @@ ], "scripts": { "test": "jest --coverage", - "build": "rm -rf ./lib/ && tsc --project tsconfig.build.json", + "build": "rm -rf ./lib/ && tsc --project tsconfig.build.json && npx webpack", "publish": "npm run build && npm publish", "prettier": "prettier --write 'src/**/*'", "lint": "eslint ./src/ --fix && tsc --noEmit", @@ -43,7 +43,8 @@ "homepage": "https://github.com/sqlitecloud/sqlitecloud-js#readme", "dependencies": { "eventemitter3": "^5.0.1", - "lz4js": "^0.2.0" + "lz4js": "^0.2.0", + "socket.io-client": "^4.7.4" }, "devDependencies": { "@types/jest": "^29.5.11", @@ -63,7 +64,9 @@ "ts-node": "^10.9.2", "typedoc": "^0.25.3", "typedoc-plugin-markdown": "^3.17.1", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "webpack": "^5.90.0", + "webpack-cli": "^5.1.4" }, "config": {}, "release": { diff --git a/src/connection.ts b/src/connection.ts index 79eba08..3a618bc 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -2,47 +2,17 @@ * connection.ts - handles low level communication with sqlitecloud server */ -import tls, { TLSSocket } from 'tls' - -const lz4 = require('lz4js') - -import { SQLiteCloudConfig, SQLCloudRowsetMetadata, SQLiteCloudError, SQLiteCloudDataTypes, ErrorCallback, ResultsCallback } from './types' -import { SQLiteCloudRowset } from './rowset' -import { parseConnectionString, parseBoolean } from './utilities' - -/** - * The server communicates with clients via commands defined - * in the SQLiteCloud Server Protocol (SCSP), see more at: - * https://github.com/sqlitecloud/sdk/blob/master/PROTOCOL.md - */ -const CMD_STRING = '+' -const CMD_ZEROSTRING = '!' -const CMD_ERROR = '-' -const CMD_INT = ':' -const CMD_FLOAT = ',' -const CMD_ROWSET = '*' -const CMD_ROWSET_CHUNK = '/' -const CMD_JSON = '#' -const CMD_NULL = '_' -const CMD_BLOB = '$' -const CMD_COMPRESSED = '%' -const CMD_COMMAND = '^' -const CMD_ARRAY = '=' -// const CMD_RAWJSON = '{' -// const CMD_PUBSUB = '|' -// const CMD_RECONNECT = '@' +import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback } from './types' +import { parseConnectionString, parseBoolean, isBrowser, isNode } from './utilities' /** Default timeout value for queries */ export const DEFAULT_TIMEOUT = 300 * 1000 - /** Default tls connection port */ export const DEFAULT_PORT = 9960 /** - * SQLiteCloud low-level connection, will do messaging, handle socket, authentication, etc. - * A connection socket is established when the connection is created and closed when the connection is closed. - * All operations are serialized by waiting for any pending operations to complete. Once a connection is closed, - * it cannot be reopened and you must create a new connection. + * Base class for SQLiteCloudConnection handles basics and defines methods. + * Actual connection management and communication with the server in concrete classes. */ export class SQLiteCloudConnection { /** Parse and validate provided connectionString or configuration */ @@ -52,17 +22,19 @@ export class SQLiteCloudConnection { } else { this.config = this.validateConfiguration(config) } + + // connect transport layer to server this.connect(callback) } /** Configuration passed by client or extracted from connection string */ - private config: SQLiteCloudConfig + protected config: SQLiteCloudConfig - /** Currently opened tls socket used to communicated with SQLiteCloud server */ - private socket?: tls.TLSSocket | null + /** Transport used to communicate with server */ + protected transport?: ConnectionTransport /** Operations are serialized by waiting an any pending promises */ - private operations = new OperationsQueue() + protected operations = new OperationsQueue() // // public properties @@ -70,7 +42,51 @@ export class SQLiteCloudConnection { /** True if connection is open */ public get connected(): boolean { - return !!this.socket + return this.transport?.connected || false + } + + /** Connect will establish a tls or websocket transport to the server based on configuration and environment */ + protected connect(callback?: ErrorCallback): this { + this.operations.enqueue(done => { + // connect using websocket if tls is not supported or if explicitly requested + if (isBrowser || this.config?.useWebsocket || this.config?.gatewayUrl) { + // socket.io transport works in both node.js and browser environments and connects via SQLite Cloud Gateway + import('./transport-ws') + .then(transport => { + this.transport = new transport.WebSocketTransport() + this.transport.connect(this.config, error => { + if (error) { + console.error(`SQLiteCloudConnection.connect - error while connecting WebSocketTransport: ${error.toString()}`, this.config, error) + this.close() + } + callback?.call(this, error || null) + done(error) + }) + }) + .catch(error => { + done(error) + }) + } else { + // tls sockets work only in node.js environments + import('./transport-tls') + .then(transport => { + this.transport = new transport.TlsSocketTransport() + this.transport.connect(this.config, error => { + if (error) { + console.error(`SQLiteCloudConnection.connect - error while connecting TlsSocketTransport: ${error.toString()}`, this.config, error) + this.close() + } + callback?.call(this, error || null) + done(error) + }) + }) + .catch(error => { + done(error) + }) + } + }) + + return this } // @@ -78,11 +94,12 @@ export class SQLiteCloudConnection { // /** Validate configuration, apply defaults, throw if something is missing or misconfigured */ - private validateConfiguration(config: SQLiteCloudConfig): SQLiteCloudConfig { + protected validateConfiguration(config: SQLiteCloudConfig): SQLiteCloudConfig { if (config.connectionString) { config = { ...config, - ...parseConnectionString(config.connectionString) + ...parseConnectionString(config.connectionString), + connectionString: config.connectionString // keep original connection string } } @@ -99,240 +116,25 @@ export class SQLiteCloudConnection { config.sqliteMode = parseBoolean(config.sqliteMode) if (!config.username || !config.password || !config.host) { + console.error(`SQLiteCloudConnection.validateConfiguration - missing arguments`, config) throw new SQLiteCloudError('The user, password and host arguments must be specified.', { errorCode: 'ERR_MISSING_ARGS' }) } + if (!config.connectionString) { + config.connectionString = `sqlitecloud://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}` + } + return config } /** Will log to console if verbose mode is enabled */ - private log(message: string, ...optionalParams: any[]): void { + protected log(message: string, ...optionalParams: any[]): void { if (this.config.verbose) { message = anonimizeCommand(message) console.log(`${new Date().toISOString()} ${this.config.clientId as string}: ${message}`, ...optionalParams) } } - /** Initialization commands sent to database when connection is established */ - private get initializationCommands(): string { - // first user authentication, then all other commands - const config = this.config - let commands = `AUTH USER ${config.username || ''} ${config.passwordHashed ? 'HASH' : 'PASSWORD'} ${config.password || ''}; ` - - if (config.database) { - if (config.createDatabase && !config.dbMemory) { - commands += `CREATE DATABASE ${config.database} IF NOT EXISTS; ` - } - commands += `USE DATABASE ${config.database}; ` - } - if (config.sqliteMode) { - commands += 'SET CLIENT KEY SQLITE TO 1; ' - } - if (config.compression) { - commands += 'SET CLIENT KEY COMPRESSION TO 1; ' - } - if (config.nonlinearizable) { - commands += 'SET CLIENT KEY NONLINEARIZABLE TO 1; ' - } - if (config.noBlob) { - commands += 'SET CLIENT KEY NOBLOB TO 1; ' - } - if (config.maxData) { - commands += `SET CLIENT KEY MAXDATA TO ${config.maxData}; ` - } - if (config.maxRows) { - commands += `SET CLIENT KEY MAXROWS TO ${config.maxRows}; ` - } - if (config.maxRowset) { - commands += `SET CLIENT KEY MAXROWSET TO ${config.maxRowset}; ` - } - - return commands - } - - /* Opens a connection with the server and sends the initialization commands. Will throw in case of errors. */ - private connect(callback?: ErrorCallback): this { - this.operations.enqueue(done => { - // connection established while we were waiting in line? - console.assert(!this.connected, 'Connection already established') - - // clear all listeners and call done in the operations queue - const finish: ResultsCallback = error => { - if (this.socket) { - this.socket.removeAllListeners('data') - this.socket.removeAllListeners('error') - this.socket.removeAllListeners('close') - if (error) { - this.close() - } - } - done(error) - } - - // connect to tls socket, initialize connection, setup event handlers - this.socket = tls.connect(this.config.port as number, this.config.host, this.config.tlsOptions, () => { - if (!this.socket?.authorized) { - const anonimizedError = anonimizeError((this.socket as TLSSocket).authorizationError) - this.log('Connection was not authorized', anonimizedError) - this.close() - finish(new SQLiteCloudError('Connection was not authorized', { cause: anonimizedError })) - } else { - // the connection was closed before it was even opened, - // eg. client closed the connection before the server accepted it - if (this.socket === null) { - this.log('Connection was closed before it was opened') - finish(new SQLiteCloudError('Connection was closed before it was done opening')) - return - } - - // send initialization commands - console.assert(this.socket, 'Connection already closed') - const commands = this.initializationCommands - this.processCommands(commands, error => { - if (error && this.socket) { - this.close() - } - if (callback) { - callback?.call(this, error) - callback = undefined - } - finish(error) - }) - } - }) - - this.socket.on('close', () => { - this.log('Connection closed') - this.socket = null - finish(new SQLiteCloudError('Connection was closed')) - }) - - this.socket.once('error', (error: any) => { - this.log('Connection error', error) - finish(new SQLiteCloudError('Connection error', { cause: error })) - }) - }) - - return this - } - - /** Will send a command immediately (no queueing), return the rowset/result or throw an error */ - private processCommands(commands: string, callback?: ResultsCallback): this { - // connection needs to be established? - if (!this.socket) { - callback?.call(this, new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' })) - return this - } - - // compose commands following SCPC protocol - commands = formatCommand(commands) - - let buffer = Buffer.alloc(0) - const rowsetChunks: Buffer[] = [] - const startedOn = new Date() - this.log(`Send: ${commands}`) - - // define what to do if an answer does not arrive within the set timeout - let socketTimeout: number - - // clear all listeners and call done in the operations queue - const finish: ResultsCallback = (error, result) => { - clearTimeout(socketTimeout) - if (this.socket) { - this.socket.removeAllListeners('data') - this.socket.removeAllListeners('error') - this.socket.removeAllListeners('close') - } - if (callback) { - callback?.call(this, error, result) - callback = undefined - } - } - - // define the Promise that waits for the server response - const readData = (data: Uint8Array) => { - try { - // on first ondata event, dataType is read from data, on subsequent ondata event, is read from buffer that is the concatanations of data received on each ondata event - let dataType = buffer.length === 0 ? data.subarray(0, 1).toString() : buffer.subarray(0, 1).toString('utf8') - buffer = Buffer.concat([buffer, data]) - const commandLength = hasCommandLength(dataType) - - if (commandLength) { - const commandLength = parseCommandLength(buffer) - const hasReceivedEntireCommand = buffer.length - buffer.indexOf(' ') - 1 >= commandLength ? true : false - if (hasReceivedEntireCommand) { - if (this.config.verbose) { - let bufferString = buffer.toString('utf8') - if (bufferString.length > 1000) { - bufferString = bufferString.substring(0, 100) + '...' + bufferString.substring(bufferString.length - 40) - } - const elapsedMs = new Date().getTime() - startedOn.getTime() - this.log(`Receive: ${bufferString} - ${elapsedMs}ms`) - } - - // need to decompress this buffer before decoding? - if (dataType === CMD_COMPRESSED) { - ;({ buffer, dataType } = decompressBuffer(buffer)) - } - - if (dataType !== CMD_ROWSET_CHUNK) { - this.socket?.off('data', readData) - const { data } = popData(buffer) - finish(null, data) - } else { - // @ts-expect-error - // check if rowset received the ending chunk - if (data.subarray(data.indexOf(' ') + 1, data.length).toString() === '0 0 0 ') { - const parsedData = parseRowsetChunks(rowsetChunks) - finish?.call(this, null, parsedData) - } else { - // no ending string? ask server for another chunk - rowsetChunks.push(buffer) - buffer = Buffer.alloc(0) - const okCommand = formatCommand('OK') - this.log(`Send: ${okCommand}`) - this.socket?.write(okCommand) - } - } - } - } else { - // command with no explicit len so make sure that the final character is a space - const lastChar = buffer.subarray(buffer.length - 1, buffer.length).toString('utf8') - if (lastChar == ' ') { - const { data } = popData(buffer) - finish(null, data) - } - } - } catch (error) { - console.assert(error instanceof Error) - if (error instanceof Error) { - finish(error) - } - } - } - - this.socket?.once('close', () => { - finish(new SQLiteCloudError('Connection was closed', { cause: anonimizeCommand(commands) })) - }) - - this.socket?.write(commands, 'utf8', () => { - socketTimeout = setTimeout(() => { - const timeoutError = new SQLiteCloudError('Request timed out', { cause: anonimizeCommand(commands) }) - this.log(`Request timed out, config.timeout is ${this.config.timeout as number}ms`, timeoutError) - finish(timeoutError) - }, this.config.timeout) - this.socket?.on('data', readData) - }) - - this.socket?.once('error', (error: any) => { - this.log('Socket error', error) - this.close() - finish(new SQLiteCloudError('Socket error', { cause: anonimizeError(error) })) - }) - - return this - } - // // public methods // @@ -345,10 +147,16 @@ export class SQLiteCloudConnection { /** Will enquee a command to be executed and callback with the resulting rowset/result/error */ public sendCommands(commands: string, callback?: ResultsCallback): this { this.operations.enqueue(done => { - this.processCommands(commands, (error, result) => { - callback?.call(this, error, result) + if (this.transport) { + this.transport.processCommands(commands, (error, result) => { + callback?.call(this, error, result) + done(error) + }) + } else { + const error = new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' }) + callback?.call(this, error) done(error) - }) + } }) return this @@ -356,13 +164,9 @@ export class SQLiteCloudConnection { /** Disconnect from server, release connection. */ public close(): this { - console.assert(this.socket !== null, 'SQLiteCloudConnection.close - connection already closed') - if (this.socket) { - this.socket.destroy() - this.socket = null - } this.operations.clear() - this.socket = undefined + this.transport?.close() + this.transport = undefined return this } } @@ -417,285 +221,71 @@ export class OperationsQueue { // utility functions // -/** Analyze first character to check if corresponding data type has LEN */ -function hasCommandLength(firstCharacter: string): boolean { - return firstCharacter == CMD_INT || firstCharacter == CMD_FLOAT || firstCharacter == CMD_NULL ? false : true -} - -/** Analyze a command with explict LEN and extract it */ -function parseCommandLength(data: Buffer) { - return parseInt(data.subarray(1, data.indexOf(' ')).toString('utf8')) -} - -/** Receive a compressed buffer, decompress with lz4, return buffer and datatype */ -function decompressBuffer(buffer: Buffer): { buffer: Buffer; dataType: string } { - const spaceIndex = buffer.indexOf(' ') - buffer = buffer.subarray(spaceIndex + 1) - - // extract compressed size - const compressedSize = parseInt(buffer.subarray(0, buffer.indexOf(' ') + 1).toString('utf8')) - buffer = buffer.subarray(buffer.indexOf(' ') + 1) - - // extract decompressed size - const decompressedSize = parseInt(buffer.subarray(0, buffer.indexOf(' ') + 1).toString('utf8')) - buffer = buffer.subarray(buffer.indexOf(' ') + 1) - - // extract compressed dataType - const dataType = buffer.subarray(0, 1).toString('utf8') - const decompressedBuffer = Buffer.alloc(decompressedSize) - const compressedBuffer = buffer.subarray(buffer.length - compressedSize) - - // lz4js library is javascript and doesn't have types so we silence the type check - // eslint-disable-next-line - const decompressionResult: number = lz4.decompressBlock(compressedBuffer, decompressedBuffer, 0, compressedSize, 0) - buffer = Buffer.concat([buffer.subarray(0, buffer.length - compressedSize), decompressedBuffer]) - if (decompressionResult <= 0 || decompressionResult !== decompressedSize) { - throw new Error(`lz4 decompression error at offset ${decompressionResult}`) - } - - return { buffer, dataType: dataType } -} - -/** Parse error message or extended error message */ -function parseError(buffer: Buffer, spaceIndex: number): never { - const errorBuffer = buffer.subarray(spaceIndex + 1) - const errorString = errorBuffer.toString('utf8') - const parts = errorString.split(' ') - - let errorCodeStr = parts.shift() || '0' // Default errorCode is '0' if not present - let extErrCodeStr = '0' // Default extended error code - let offsetCodeStr = '-1' // Default offset code - - // Split the errorCode by ':' to check for extended error codes - const errorCodeParts = errorCodeStr.split(':') - errorCodeStr = errorCodeParts[0] - if (errorCodeParts.length > 1) { - extErrCodeStr = errorCodeParts[1] - if (errorCodeParts.length > 2) { - offsetCodeStr = errorCodeParts[2] - } - } - - // Rest of the error string is the error message - const errorMessage = parts.join(' ') - - // Parse error codes to integers safely, defaulting to 0 if NaN - const errorCode = parseInt(errorCodeStr) - const extErrCode = parseInt(extErrCodeStr) - const offsetCode = parseInt(offsetCodeStr) - - // create an Error object and add the custom properties - throw new SQLiteCloudError(errorMessage, { - errorCode: errorCode.toString(), - externalErrorCode: extErrCode.toString(), - offsetCode - }) -} - -/** Parse an array of items (each of which will be parsed by type separately) */ -function parseArray(buffer: Buffer, spaceIndex: number): SQLiteCloudDataTypes[] { - const parsedData = [] - - const array = buffer.subarray(spaceIndex + 1, buffer.length) - const numberOfItems = parseInt(array.subarray(0, spaceIndex - 2).toString('utf8')) - let arrayItems = array.subarray(array.indexOf(' ') + 1, array.length) - - for (let i = 0; i < numberOfItems; i++) { - const { data, fwdBuffer: buffer } = popData(arrayItems) - parsedData.push(data) - arrayItems = buffer - } - - return parsedData as SQLiteCloudDataTypes[] +/** Messages going to the server are sometimes logged when error conditions occour and need to be stripped of user credentials */ +export function anonimizeCommand(message: string): string { + // hide password in AUTH command if needed + message = message.replace(/USER \S+/, 'USER ******') + message = message.replace(/PASSWORD \S+?(?=;)/, 'PASSWORD ******') + message = message.replace(/HASH \S+?(?=;)/, 'HASH ******') + return message } -/** Parse header in a rowset or chunk of a chunked rowset */ -function parseRowsetHeader(buffer: Buffer): { index: number; metadata: SQLCloudRowsetMetadata; fwdBuffer: Buffer } { - const index = parseInt(buffer.subarray(0, buffer.indexOf(':') + 1).toString()) - buffer = buffer.subarray(buffer.indexOf(':') + 1) - - // extract rowset header - const { data, fwdBuffer } = popIntegers(buffer, 3) - - return { - index, - metadata: { - version: data[0], - numberOfRows: data[1], - numberOfColumns: data[2], - columns: [] - }, - fwdBuffer +/** Strip message code in error of user credentials */ +export function anonimizeError(error: Error): Error { + if (error?.message) { + error.message = anonimizeCommand(error.message) } + return error } -/** Extract column names and, optionally, more metadata out of a rowset's header */ -function parseRowsetColumnsMetadata(buffer: Buffer, metadata: SQLCloudRowsetMetadata): Buffer { - function popForward() { - const { data, fwdBuffer: fwdBuffer } = popData(buffer) // buffer in parent scope - buffer = fwdBuffer - return data - } - - for (let i = 0; i < metadata.numberOfColumns; i++) { - metadata.columns.push({ name: popForward() as string }) - } +/** Initialization commands sent to database when connection is established */ +export function getInitializationCommands(config: SQLiteCloudConfig): string { + // first user authentication, then all other commands + let commands = `AUTH USER ${config.username || ''} ${config.passwordHashed ? 'HASH' : 'PASSWORD'} ${config.password || ''}; ` - // extract additional metadata if rowset has version 2 - if (metadata.version == 2) { - for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].type = popForward() as string - for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].database = popForward() as string - for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].table = popForward() as string - for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].column = popForward() as string // original column name + if (config.database) { + if (config.createDatabase && !config.dbMemory) { + commands += `CREATE DATABASE ${config.database} IF NOT EXISTS; ` + } + commands += `USE DATABASE ${config.database}; ` } - - return buffer -} - -/** Parse a regular rowset (no chunks) */ -function parseRowset(buffer: Buffer, spaceIndex: number): SQLiteCloudRowset { - buffer = buffer.subarray(spaceIndex + 1, buffer.length) - - const { metadata, fwdBuffer } = parseRowsetHeader(buffer) - buffer = parseRowsetColumnsMetadata(fwdBuffer, metadata) - - // decode each rowset item - const data = [] - for (let j = 0; j < metadata.numberOfRows * metadata.numberOfColumns; j++) { - const { data: rowData, fwdBuffer } = popData(buffer) - data.push(rowData) - buffer = fwdBuffer + if (config.sqliteMode) { + commands += 'SET CLIENT KEY SQLITE TO 1; ' } - - console.assert(data && data.length === metadata.numberOfRows * metadata.numberOfColumns, 'SQLiteCloudConnection.parseRowset - invalid rowset data') - return new SQLiteCloudRowset(metadata, data) -} - -/** - * Parse a chunk of a chunked rowset command, eg: - * *LEN 0:VERS NROWS NCOLS DATA - */ -function parseRowsetChunks(buffers: Buffer[]) { - let metadata: SQLCloudRowsetMetadata = { version: 1, numberOfColumns: 0, numberOfRows: 0, columns: [] } - const data = [] - - for (let i = 0; i < buffers.length; i++) { - let buffer: Buffer = buffers[i] - - // validate and skip data type - const dataType = buffer.subarray(0, 1).toString() - console.assert(dataType === CMD_ROWSET_CHUNK) - buffer = buffer.subarray(buffer.indexOf(' ') + 1) - - // chunk header, eg: 0:VERS NROWS NCOLS - const { index: chunkIndex, metadata: chunkMetadata, fwdBuffer } = parseRowsetHeader(buffer) - buffer = fwdBuffer - - // first chunk? extract columns metadata - if (chunkIndex === 1) { - metadata = chunkMetadata - buffer = parseRowsetColumnsMetadata(buffer, metadata) - } else { - metadata.numberOfRows += chunkMetadata.numberOfRows - } - - // extract single rowset row - for (let k = 0; k < chunkMetadata.numberOfRows * metadata.numberOfColumns; k++) { - const { data: itemData, fwdBuffer } = popData(buffer) - data.push(itemData) - buffer = fwdBuffer - } + if (config.compression) { + commands += 'SET CLIENT KEY COMPRESSION TO 1; ' } - - console.assert(data && data.length === metadata.numberOfRows * metadata.numberOfColumns, 'SQLiteCloudConnection.parseRowsetChunks - invalid rowset data') - return new SQLiteCloudRowset(metadata, data) -} - -/** Pop one or more space separated integers from beginning of buffer, move buffer forward */ -function popIntegers(buffer: Buffer, numberOfIntegers = 1): { data: number[]; fwdBuffer: Buffer } { - const data: number[] = [] - for (let i = 0; i < numberOfIntegers; i++) { - const spaceIndex = buffer.indexOf(' ') - data[i] = parseInt(buffer.subarray(0, spaceIndex).toString()) - buffer = buffer.subarray(spaceIndex + 1) + if (config.nonlinearizable) { + commands += 'SET CLIENT KEY NONLINEARIZABLE TO 1; ' } - return { data, fwdBuffer: buffer } -} - -/** Parse command, extract its data, return the data and the buffer moved to the first byte after the command */ -function popData(buffer: Buffer): { data: SQLiteCloudDataTypes | SQLiteCloudRowset; fwdBuffer: Buffer } { - function popResults(data: any) { - const fwdBuffer = buffer.subarray(commandEnd) - return { data, fwdBuffer } + if (config.noBlob) { + commands += 'SET CLIENT KEY NOBLOB TO 1; ' } - - // first character is the data type - console.assert(buffer && buffer instanceof Buffer) - const dataType: string = buffer.subarray(0, 1).toString('utf8') - console.assert(dataType !== CMD_COMPRESSED, "Compressed data shouldn't be decompressed before parsing") - console.assert(dataType !== CMD_ROWSET_CHUNK, 'Chunked data should be parsed by parseRowsetChunks') - - let spaceIndex = buffer.indexOf(' ') - if (spaceIndex === -1) { - spaceIndex = buffer.length - 1 + if (config.maxData) { + commands += `SET CLIENT KEY MAXDATA TO ${config.maxData}; ` } - - let commandEnd = -1 - if (dataType === CMD_INT || dataType === CMD_FLOAT || dataType === CMD_NULL) { - commandEnd = spaceIndex + 1 - } else { - const commandLength = parseInt(buffer.subarray(1, spaceIndex).toString()) - commandEnd = spaceIndex + 1 + commandLength + if (config.maxRows) { + commands += `SET CLIENT KEY MAXROWS TO ${config.maxRows}; ` } - - switch (dataType) { - case CMD_INT: - return popResults(parseInt(buffer.subarray(1, spaceIndex).toString())) - case CMD_FLOAT: - return popResults(parseFloat(buffer.subarray(1, spaceIndex).toString())) - case CMD_NULL: - return popResults(null) - case CMD_STRING: - return popResults(buffer.subarray(spaceIndex + 1, commandEnd).toString('utf8')) - case CMD_ZEROSTRING: - return popResults(buffer.subarray(spaceIndex + 1, commandEnd - 1).toString('utf8')) - case CMD_COMMAND: - return popResults(buffer.subarray(spaceIndex + 1, commandEnd).toString('utf8')) - case CMD_JSON: - return popResults(JSON.parse(buffer.subarray(spaceIndex + 1, commandEnd).toString('utf8'))) - case CMD_BLOB: - return popResults(buffer.subarray(spaceIndex + 1, commandEnd)) - case CMD_ARRAY: - return popResults(parseArray(buffer, spaceIndex)) - case CMD_ROWSET: - return popResults(parseRowset(buffer, spaceIndex)) - case CMD_ERROR: - parseError(buffer, spaceIndex) // throws custom error - break + if (config.maxRowset) { + commands += `SET CLIENT KEY MAXROWSET TO ${config.maxRowset}; ` } - throw new TypeError(`Data type: ${dataType} is not defined in SCSP`) + return commands } -/** Format a command to be sent via SCSP protocol */ -function formatCommand(command: string): string { - const commandLength = Buffer.byteLength(command, 'utf-8') - return `+${commandLength} ${command}` -} - -/** Messages going to the server are sometimes logged when error conditions occour and need to be stripped of user credentials */ -export function anonimizeCommand(message: string): string { - // hide password in AUTH command if needed - message = message.replace(/USER \S+/, 'USER ******') - message = message.replace(/PASSWORD \S+?(?=;)/, 'PASSWORD ******') - message = message.replace(/HASH \S+?(?=;)/, 'HASH ******') - return message -} +// +// ConnectionTransport +// -/** Strip message code in error of user credentials */ -export function anonimizeError(error: Error): Error { - if (error?.message) { - error.message = anonimizeCommand(error.message) - } - return error +/** ConnectionTransport implements the underlying transport layer for the connection */ +export interface ConnectionTransport { + /** True if connection is currently open */ + get connected(): boolean + /* Opens a connection with the server and sends the initialization commands. Will throw in case of errors. */ + connect(config: SQLiteCloudConfig, callback?: ErrorCallback): this + /** Send a command, return the rowset/result or throw an error */ + processCommands(commands: string, callback?: ResultsCallback): this + /** Disconnect from server, release transport. */ + close(): this } diff --git a/src/database.ts b/src/database.ts index baaa4cd..658a8b0 100644 --- a/src/database.ts +++ b/src/database.ts @@ -123,9 +123,9 @@ export class Database extends EventEmitter { /** Emits given event with optional arguments on the next tick so callbacks can complete first */ private emitEvent(event: string, ...args: any[]): void { - process.nextTick(() => { + setTimeout(() => { this.emit(event, ...args) - }) + }, 0) } // diff --git a/src/index.ts b/src/index.ts index 72f02d9..636e81d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,11 @@ // -// index.ts - re-export all the public APIs +// index.ts - re-export public APIs // export { Database } from './database' export { Statement } from './statement' - export { SQLiteCloudConfig, SQLCloudRowsetMetadata, SQLiteCloudError, ErrorCallback } from './types' export { SQLiteCloudRowset, SQLiteCloudRow } from './rowset' export { SQLiteCloudConnection } from './connection' - export { escapeSqlParameter, prepareSql } from './utilities' +export { WebSocketTransport } from './transport-ws' diff --git a/src/transport-tls.ts b/src/transport-tls.ts new file mode 100644 index 0000000..c912527 --- /dev/null +++ b/src/transport-tls.ts @@ -0,0 +1,515 @@ +/** + * transport-tls.ts - handles low level communication with sqlitecloud server via tls socket and binary protocol + */ + +import { SQLiteCloudConfig, SQLCloudRowsetMetadata, SQLiteCloudError, SQLiteCloudDataTypes, ErrorCallback, ResultsCallback } from './types' +import { SQLiteCloudRowset } from './rowset' +import { ConnectionTransport, getInitializationCommands } from './connection' +import { anonimizeError, anonimizeCommand } from './connection' + +import tls, { TLSSocket } from 'tls' +const lz4 = require('lz4js') + +/** + * The server communicates with clients via commands defined + * in the SQLiteCloud Server Protocol (SCSP), see more at: + * https://github.com/sqlitecloud/sdk/blob/master/PROTOCOL.md + */ +const CMD_STRING = '+' +const CMD_ZEROSTRING = '!' +const CMD_ERROR = '-' +const CMD_INT = ':' +const CMD_FLOAT = ',' +const CMD_ROWSET = '*' +const CMD_ROWSET_CHUNK = '/' +const CMD_JSON = '#' +const CMD_NULL = '_' +const CMD_BLOB = '$' +const CMD_COMPRESSED = '%' +const CMD_COMMAND = '^' +const CMD_ARRAY = '=' +// const CMD_RAWJSON = '{' +// const CMD_PUBSUB = '|' +// const CMD_RECONNECT = '@' + +/** + * Implementation of SQLiteCloudConnection that connects directly to the database via tls socket and raw, binary protocol. + * SQLiteCloud low-level connection, will do messaging, handle socket, authentication, etc. + * A connection socket is established when the connection is created and closed when the connection is closed. + * All operations are serialized by waiting for any pending operations to complete. Once a connection is closed, + * it cannot be reopened and you must create a new connection. + */ +export class TlsSocketTransport implements ConnectionTransport { + /** Configuration passed to connect */ + private config?: SQLiteCloudConfig + /** Currently opened tls socket used to communicated with SQLiteCloud server */ + private socket?: tls.TLSSocket | null + + /** True if connection is open */ + get connected(): boolean { + return !!this.socket + } + + /* Opens a connection with the server and sends the initialization commands. Will throw in case of errors. */ + connect(config: SQLiteCloudConfig, callback?: ErrorCallback): this { + // connection established while we were waiting in line? + console.assert(!this.connected, 'Connection already established') + + // clear all listeners and call done in the operations queue + const finish: ResultsCallback = error => { + if (this.socket) { + this.socket.removeAllListeners('data') + this.socket.removeAllListeners('error') + this.socket.removeAllListeners('close') + if (error) { + this.close() + } + } + } + + this.config = config + + // connect to tls socket, initialize connection, setup event handlers + this.socket = tls.connect(this.config.port as number, this.config.host, this.config.tlsOptions, () => { + if (!this.socket?.authorized) { + const anonimizedError = anonimizeError((this.socket as TLSSocket).authorizationError) + console.debug('Connection was not authorized', anonimizedError) + this.close() + finish(new SQLiteCloudError('Connection was not authorized', { cause: anonimizedError })) + } else { + // the connection was closed before it was even opened, + // eg. client closed the connection before the server accepted it + if (this.socket === null) { + console.debug('Connection was closed before it was opened') + finish(new SQLiteCloudError('Connection was closed before it was done opening')) + return + } + + // send initialization commands + console.assert(this.socket, 'Connection already closed') + const commands = getInitializationCommands(config) + this.processCommands(commands, error => { + if (error && this.socket) { + this.close() + } + if (callback) { + callback?.call(this, error) + callback = undefined + } + finish(error) + }) + } + }) + + this.socket.on('close', () => { + console.debug('Connection closed') + this.socket = null + finish(new SQLiteCloudError('Connection was closed')) + }) + + this.socket.once('error', (error: any) => { + console.debug('Connection error', error) + finish(new SQLiteCloudError('Connection error', { cause: error })) + }) + + return this + } + + /** Will send a command immediately (no queueing), return the rowset/result or throw an error */ + processCommands(commands: string, callback?: ResultsCallback): this { + // connection needs to be established? + if (!this.socket) { + callback?.call(this, new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' })) + return this + } + + // compose commands following SCPC protocol + commands = formatCommand(commands) + + let buffer = Buffer.alloc(0) + const rowsetChunks: Buffer[] = [] + const startedOn = new Date() + console.debug(`Send: ${commands}`) + + // define what to do if an answer does not arrive within the set timeout + let socketTimeout: number + + // clear all listeners and call done in the operations queue + const finish: ResultsCallback = (error, result) => { + clearTimeout(socketTimeout) + if (this.socket) { + this.socket.removeAllListeners('data') + this.socket.removeAllListeners('error') + this.socket.removeAllListeners('close') + } + if (callback) { + callback?.call(this, error, result) + callback = undefined + } + } + + // define the Promise that waits for the server response + const readData = (data: Uint8Array) => { + try { + // on first ondata event, dataType is read from data, on subsequent ondata event, is read from buffer that is the concatanations of data received on each ondata event + let dataType = buffer.length === 0 ? data.subarray(0, 1).toString() : buffer.subarray(0, 1).toString('utf8') + buffer = Buffer.concat([buffer, data]) + const commandLength = hasCommandLength(dataType) + + if (commandLength) { + const commandLength = parseCommandLength(buffer) + const hasReceivedEntireCommand = buffer.length - buffer.indexOf(' ') - 1 >= commandLength ? true : false + if (hasReceivedEntireCommand) { + if (this.config?.verbose) { + let bufferString = buffer.toString('utf8') + if (bufferString.length > 1000) { + bufferString = bufferString.substring(0, 100) + '...' + bufferString.substring(bufferString.length - 40) + } + const elapsedMs = new Date().getTime() - startedOn.getTime() + console.debug(`Receive: ${bufferString} - ${elapsedMs}ms`) + } + + // need to decompress this buffer before decoding? + if (dataType === CMD_COMPRESSED) { + ;({ buffer, dataType } = decompressBuffer(buffer)) + } + + if (dataType !== CMD_ROWSET_CHUNK) { + this.socket?.off('data', readData) + const { data } = popData(buffer) + finish(null, data) + } else { + // @ts-expect-error + // check if rowset received the ending chunk + if (data.subarray(data.indexOf(' ') + 1, data.length).toString() === '0 0 0 ') { + const parsedData = parseRowsetChunks(rowsetChunks) + finish?.call(this, null, parsedData) + } else { + // no ending string? ask server for another chunk + rowsetChunks.push(buffer) + buffer = Buffer.alloc(0) + const okCommand = formatCommand('OK') + console.debug(`Send: ${okCommand}`) + this.socket?.write(okCommand) + } + } + } + } else { + // command with no explicit len so make sure that the final character is a space + const lastChar = buffer.subarray(buffer.length - 1, buffer.length).toString('utf8') + if (lastChar == ' ') { + const { data } = popData(buffer) + finish(null, data) + } + } + } catch (error) { + console.assert(error instanceof Error) + if (error instanceof Error) { + finish(error) + } + } + } + + this.socket?.once('close', () => { + finish(new SQLiteCloudError('Connection was closed', { cause: anonimizeCommand(commands) })) + }) + + this.socket?.write(commands, 'utf8', () => { + socketTimeout = setTimeout(() => { + const timeoutError = new SQLiteCloudError('Request timed out', { cause: anonimizeCommand(commands) }) + console.debug(`Request timed out, config.timeout is ${this.config?.timeout as number}ms`, timeoutError) + finish(timeoutError) + }, this.config?.timeout) + this.socket?.on('data', readData) + }) + + this.socket?.once('error', (error: any) => { + console.debug('Socket error', error) + this.close() + finish(new SQLiteCloudError('Socket error', { cause: anonimizeError(error) })) + }) + + return this + } + + /** Disconnect from server, release connection. */ + close(): this { + console.assert(this.socket !== null, 'TlsSocketTransport.close - connection already closed') + if (this.socket) { + this.socket.destroy() + this.socket = null + } + this.socket = undefined + return this + } +} + +// +// utility functions +// + +/** Analyze first character to check if corresponding data type has LEN */ +function hasCommandLength(firstCharacter: string): boolean { + return firstCharacter == CMD_INT || firstCharacter == CMD_FLOAT || firstCharacter == CMD_NULL ? false : true +} + +/** Analyze a command with explict LEN and extract it */ +function parseCommandLength(data: Buffer) { + return parseInt(data.subarray(1, data.indexOf(' ')).toString('utf8')) +} + +/** Receive a compressed buffer, decompress with lz4, return buffer and datatype */ +function decompressBuffer(buffer: Buffer): { buffer: Buffer; dataType: string } { + const spaceIndex = buffer.indexOf(' ') + buffer = buffer.subarray(spaceIndex + 1) + + // extract compressed size + const compressedSize = parseInt(buffer.subarray(0, buffer.indexOf(' ') + 1).toString('utf8')) + buffer = buffer.subarray(buffer.indexOf(' ') + 1) + + // extract decompressed size + const decompressedSize = parseInt(buffer.subarray(0, buffer.indexOf(' ') + 1).toString('utf8')) + buffer = buffer.subarray(buffer.indexOf(' ') + 1) + + // extract compressed dataType + const dataType = buffer.subarray(0, 1).toString('utf8') + const decompressedBuffer = Buffer.alloc(decompressedSize) + const compressedBuffer = buffer.subarray(buffer.length - compressedSize) + + // lz4js library is javascript and doesn't have types so we silence the type check + // eslint-disable-next-line + const decompressionResult: number = lz4.decompressBlock(compressedBuffer, decompressedBuffer, 0, compressedSize, 0) + buffer = Buffer.concat([buffer.subarray(0, buffer.length - compressedSize), decompressedBuffer]) + if (decompressionResult <= 0 || decompressionResult !== decompressedSize) { + throw new Error(`lz4 decompression error at offset ${decompressionResult}`) + } + + return { buffer, dataType: dataType } +} + +/** Parse error message or extended error message */ +function parseError(buffer: Buffer, spaceIndex: number): never { + const errorBuffer = buffer.subarray(spaceIndex + 1) + const errorString = errorBuffer.toString('utf8') + const parts = errorString.split(' ') + + let errorCodeStr = parts.shift() || '0' // Default errorCode is '0' if not present + let extErrCodeStr = '0' // Default extended error code + let offsetCodeStr = '-1' // Default offset code + + // Split the errorCode by ':' to check for extended error codes + const errorCodeParts = errorCodeStr.split(':') + errorCodeStr = errorCodeParts[0] + if (errorCodeParts.length > 1) { + extErrCodeStr = errorCodeParts[1] + if (errorCodeParts.length > 2) { + offsetCodeStr = errorCodeParts[2] + } + } + + // Rest of the error string is the error message + const errorMessage = parts.join(' ') + + // Parse error codes to integers safely, defaulting to 0 if NaN + const errorCode = parseInt(errorCodeStr) + const extErrCode = parseInt(extErrCodeStr) + const offsetCode = parseInt(offsetCodeStr) + + // create an Error object and add the custom properties + throw new SQLiteCloudError(errorMessage, { + errorCode: errorCode.toString(), + externalErrorCode: extErrCode.toString(), + offsetCode + }) +} + +/** Parse an array of items (each of which will be parsed by type separately) */ +function parseArray(buffer: Buffer, spaceIndex: number): SQLiteCloudDataTypes[] { + const parsedData = [] + + const array = buffer.subarray(spaceIndex + 1, buffer.length) + const numberOfItems = parseInt(array.subarray(0, spaceIndex - 2).toString('utf8')) + let arrayItems = array.subarray(array.indexOf(' ') + 1, array.length) + + for (let i = 0; i < numberOfItems; i++) { + const { data, fwdBuffer: buffer } = popData(arrayItems) + parsedData.push(data) + arrayItems = buffer + } + + return parsedData as SQLiteCloudDataTypes[] +} + +/** Parse header in a rowset or chunk of a chunked rowset */ +function parseRowsetHeader(buffer: Buffer): { index: number; metadata: SQLCloudRowsetMetadata; fwdBuffer: Buffer } { + const index = parseInt(buffer.subarray(0, buffer.indexOf(':') + 1).toString()) + buffer = buffer.subarray(buffer.indexOf(':') + 1) + + // extract rowset header + const { data, fwdBuffer } = popIntegers(buffer, 3) + + return { + index, + metadata: { + version: data[0], + numberOfRows: data[1], + numberOfColumns: data[2], + columns: [] + }, + fwdBuffer + } +} + +/** Extract column names and, optionally, more metadata out of a rowset's header */ +function parseRowsetColumnsMetadata(buffer: Buffer, metadata: SQLCloudRowsetMetadata): Buffer { + function popForward() { + const { data, fwdBuffer: fwdBuffer } = popData(buffer) // buffer in parent scope + buffer = fwdBuffer + return data + } + + for (let i = 0; i < metadata.numberOfColumns; i++) { + metadata.columns.push({ name: popForward() as string }) + } + + // extract additional metadata if rowset has version 2 + if (metadata.version == 2) { + for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].type = popForward() as string + for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].database = popForward() as string + for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].table = popForward() as string + for (let i = 0; i < metadata.numberOfColumns; i++) metadata.columns[i].column = popForward() as string // original column name + } + + return buffer +} + +/** Parse a regular rowset (no chunks) */ +function parseRowset(buffer: Buffer, spaceIndex: number): SQLiteCloudRowset { + buffer = buffer.subarray(spaceIndex + 1, buffer.length) + + const { metadata, fwdBuffer } = parseRowsetHeader(buffer) + buffer = parseRowsetColumnsMetadata(fwdBuffer, metadata) + + // decode each rowset item + const data = [] + for (let j = 0; j < metadata.numberOfRows * metadata.numberOfColumns; j++) { + const { data: rowData, fwdBuffer } = popData(buffer) + data.push(rowData) + buffer = fwdBuffer + } + + console.assert(data && data.length === metadata.numberOfRows * metadata.numberOfColumns, 'SQLiteCloudConnection.parseRowset - invalid rowset data') + return new SQLiteCloudRowset(metadata, data) +} + +/** + * Parse a chunk of a chunked rowset command, eg: + * *LEN 0:VERS NROWS NCOLS DATA + */ +function parseRowsetChunks(buffers: Buffer[]) { + let metadata: SQLCloudRowsetMetadata = { version: 1, numberOfColumns: 0, numberOfRows: 0, columns: [] } + const data = [] + + for (let i = 0; i < buffers.length; i++) { + let buffer: Buffer = buffers[i] + + // validate and skip data type + const dataType = buffer.subarray(0, 1).toString() + console.assert(dataType === CMD_ROWSET_CHUNK) + buffer = buffer.subarray(buffer.indexOf(' ') + 1) + + // chunk header, eg: 0:VERS NROWS NCOLS + const { index: chunkIndex, metadata: chunkMetadata, fwdBuffer } = parseRowsetHeader(buffer) + buffer = fwdBuffer + + // first chunk? extract columns metadata + if (chunkIndex === 1) { + metadata = chunkMetadata + buffer = parseRowsetColumnsMetadata(buffer, metadata) + } else { + metadata.numberOfRows += chunkMetadata.numberOfRows + } + + // extract single rowset row + for (let k = 0; k < chunkMetadata.numberOfRows * metadata.numberOfColumns; k++) { + const { data: itemData, fwdBuffer } = popData(buffer) + data.push(itemData) + buffer = fwdBuffer + } + } + + console.assert(data && data.length === metadata.numberOfRows * metadata.numberOfColumns, 'SQLiteCloudConnection.parseRowsetChunks - invalid rowset data') + return new SQLiteCloudRowset(metadata, data) +} + +/** Pop one or more space separated integers from beginning of buffer, move buffer forward */ +function popIntegers(buffer: Buffer, numberOfIntegers = 1): { data: number[]; fwdBuffer: Buffer } { + const data: number[] = [] + for (let i = 0; i < numberOfIntegers; i++) { + const spaceIndex = buffer.indexOf(' ') + data[i] = parseInt(buffer.subarray(0, spaceIndex).toString()) + buffer = buffer.subarray(spaceIndex + 1) + } + return { data, fwdBuffer: buffer } +} + +/** Parse command, extract its data, return the data and the buffer moved to the first byte after the command */ +function popData(buffer: Buffer): { data: SQLiteCloudDataTypes | SQLiteCloudRowset; fwdBuffer: Buffer } { + function popResults(data: any) { + const fwdBuffer = buffer.subarray(commandEnd) + return { data, fwdBuffer } + } + + // first character is the data type + console.assert(buffer && buffer instanceof Buffer) + const dataType: string = buffer.subarray(0, 1).toString('utf8') + console.assert(dataType !== CMD_COMPRESSED, "Compressed data shouldn't be decompressed before parsing") + console.assert(dataType !== CMD_ROWSET_CHUNK, 'Chunked data should be parsed by parseRowsetChunks') + + let spaceIndex = buffer.indexOf(' ') + if (spaceIndex === -1) { + spaceIndex = buffer.length - 1 + } + + let commandEnd = -1 + if (dataType === CMD_INT || dataType === CMD_FLOAT || dataType === CMD_NULL) { + commandEnd = spaceIndex + 1 + } else { + const commandLength = parseInt(buffer.subarray(1, spaceIndex).toString()) + commandEnd = spaceIndex + 1 + commandLength + } + + switch (dataType) { + case CMD_INT: + return popResults(parseInt(buffer.subarray(1, spaceIndex).toString())) + case CMD_FLOAT: + return popResults(parseFloat(buffer.subarray(1, spaceIndex).toString())) + case CMD_NULL: + return popResults(null) + case CMD_STRING: + return popResults(buffer.subarray(spaceIndex + 1, commandEnd).toString('utf8')) + case CMD_ZEROSTRING: + return popResults(buffer.subarray(spaceIndex + 1, commandEnd - 1).toString('utf8')) + case CMD_COMMAND: + return popResults(buffer.subarray(spaceIndex + 1, commandEnd).toString('utf8')) + case CMD_JSON: + return popResults(JSON.parse(buffer.subarray(spaceIndex + 1, commandEnd).toString('utf8'))) + case CMD_BLOB: + return popResults(buffer.subarray(spaceIndex + 1, commandEnd)) + case CMD_ARRAY: + return popResults(parseArray(buffer, spaceIndex)) + case CMD_ROWSET: + return popResults(parseRowset(buffer, spaceIndex)) + case CMD_ERROR: + parseError(buffer, spaceIndex) // throws custom error + break + } + + throw new TypeError(`Data type: ${dataType} is not defined in SCSP`) +} + +/** Format a command to be sent via SCSP protocol */ +function formatCommand(command: string): string { + const commandLength = Buffer.byteLength(command, 'utf-8') + return `+${commandLength} ${command}` +} diff --git a/src/transport-ws.ts b/src/transport-ws.ts new file mode 100644 index 0000000..e0b2ec9 --- /dev/null +++ b/src/transport-ws.ts @@ -0,0 +1,85 @@ +/** + * transport-ws.ts - handles low level communication with sqlitecloud server via socket.io websocket + */ + +import { SQLiteCloudConfig, SQLiteCloudError, ErrorCallback, ResultsCallback } from './types' +import { SQLiteCloudRowset } from './rowset' +import { ConnectionTransport } from './connection' +import { io, Socket } from 'socket.io-client' + +/** + * Implementation of TransportConnection that connects to the database indirectly + * via SQLite Cloud Gateway, a socket.io based deamon that responds to sql query + * requests by returning results and rowsets in json format. The gateway handles + * connect, disconnect, retries, order of operations, timeouts, etc. + */ +export class WebSocketTransport implements ConnectionTransport { + /** Configuration passed to connect */ + private config?: SQLiteCloudConfig + /** Socket.io used to communicated with SQLiteCloud server */ + private socket?: Socket + + /** True if connection is open */ + get connected(): boolean { + return !!this.socket + } + + /* Opens a connection with the server and sends the initialization commands. Will throw in case of errors. */ + connect(config: SQLiteCloudConfig, callback?: ErrorCallback): this { + try { + // connection established while we were waiting in line? + console.assert(!this.connected, 'Connection already established') + if (!this.socket) { + this.config = config + let connectionString = this.config.connectionString as string + let gatewayUrl = this.config?.gatewayUrl || `ws://${this.config.host}:4000` + this.socket = io(gatewayUrl, { auth: { token: connectionString } }) + } + callback?.call(this, null) + } catch (error) { + callback?.call(this, error as Error) + } + return this + } + + /** Will send a command immediately (no queueing), return the rowset/result or throw an error */ + processCommands(commands: string, callback?: ResultsCallback): this { + // connection needs to be established? + if (!this.socket) { + callback?.call(this, new SQLiteCloudError('Connection not established', { errorCode: 'ERR_CONNECTION_NOT_ESTABLISHED' })) + return this + } + + this.socket.emit('v1/sql', { sql: commands, row: 'array' }, (response: any) => { + if (response?.error) { + const error = new SQLiteCloudError(response.error.detail, { ...response.error }) + callback?.call(this, error) + } else { + console.debug(`SQLiteCloudWebsocketConnection.processCommands - response: ${JSON.stringify(response)}`) + const { data, metadata } = response + if (data && metadata) { + if (metadata.numberOfRows !== undefined && metadata.numberOfColumns !== undefined && metadata.columns !== undefined) { + // we can recreate a SQLiteCloudRowset from the response + const rowset = new SQLiteCloudRowset(metadata, data.flat()) + callback?.call(this, null, rowset) + return + } + } + callback?.call(this, null, response?.data) + } + }) + + return this + } + + /** Disconnect socket.io from server */ + public close(): this { + console.assert(this.socket !== null, 'WebsocketTransport.close - connection already closed') + if (this.socket) { + this.socket?.close() + this.socket = undefined + } + this.socket = undefined + return this + } +} diff --git a/src/types.ts b/src/types.ts index 040d57a..6c466f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,11 @@ export interface SQLiteCloudConfig { /** Custom options and configurations for tls socket, eg: additional certificates */ tlsOptions?: tls.ConnectionOptions + /** True if we should force use of SQLite Cloud Gateway and websocket connections, default: true in browsers, false in node.js */ + useWebsocket?: boolean + /** Url where we can connect to a SQLite Cloud Gateway that has a socket.io deamon waiting to connect, eg. ws://host:4000 */ + gatewayUrl?: string + /** Optional identifier used for verbose logging */ clientId?: string /** True if connection should enable debug logs */ diff --git a/src/utilities.ts b/src/utilities.ts index 3b0a4e2..a8f0bfa 100644 --- a/src/utilities.ts +++ b/src/utilities.ts @@ -4,6 +4,14 @@ import { SQLiteCloudConfig, SQLiteCloudError, SQLiteCloudDataTypes } from './types' +// +// determining running environment, thanks to browser-or-node +// https://www.npmjs.com/package/browser-or-node +// + +export const isBrowser: boolean = typeof window !== 'undefined' && typeof window.document !== 'undefined' +export const isNode: boolean = typeof process !== 'undefined' && process.versions != null && process.versions.node != null + // // utility methods // @@ -107,26 +115,36 @@ export function popCallback( return { args: remaining } } -/** Parse connectionString like sqlitecloud://usernam:password@host:port/database?option1=xxx&option2=xxx into its components */ +/** Parse connectionString like sqlitecloud://username:password@host:port/database?option1=xxx&option2=xxx into its components */ export function parseConnectionString(connectionString: string): SQLiteCloudConfig { try { // The URL constructor throws a TypeError if the URL is not valid. - const url = new URL(connectionString) - const database = url.pathname.replace('/', '') // pathname is database name, remove the leading slash + // in spite of having the same structure as a regular url + // protocol://username:password@host:port/database?option1=xxx&option2=xxx) + // the sqlitecloud: protocol is not recognized by the URL constructor in browsers + // so we need to replace it with https: to make it work + const knownProtocolUrl = connectionString.replace('sqlitecloud:', 'https:') + const url = new URL(knownProtocolUrl) const options: { [key: string]: string } = {} url.searchParams.forEach((value, key) => { options[key] = value }) - return { + const config: SQLiteCloudConfig = { username: url.username, password: url.password, host: url.hostname, port: url.port ? parseInt(url.port) : undefined, - database, ...options } + + const database = url.pathname.replace('/', '') // pathname is database name, remove the leading slash + if (database) { + config.database = database + } + + return config } catch (error) { throw new SQLiteCloudError(`Invalid connection string: ${connectionString}`) } diff --git a/test/connection.test.ts b/test/connection-tls.test.ts similarity index 97% rename from test/connection.test.ts rename to test/connection-tls.test.ts index 0e63d61..8ca4fe4 100644 --- a/test/connection.test.ts +++ b/test/connection-tls.test.ts @@ -1,5 +1,5 @@ /** - * connection.test.ts - test low level communication protocol + * connection-tls.test.ts - test low level communication protocol with tls sockets and raw commands */ import { SQLiteCloudError } from '../src/index' @@ -9,17 +9,17 @@ import { LONG_TIMEOUT, getTestingConfig, getChinookConfig, - getChinookConnection, + getChinookTlsConnection, // clearTestingDatabasesAsync, WARN_SPEED_MS, EXPECT_SPEED_MS } from './shared' -describe('connection', () => { +describe('connection-tls', () => { let chinook: SQLiteCloudConnection beforeEach(() => { - chinook = getChinookConnection() + chinook = getChinookTlsConnection() }) afterEach(() => { @@ -264,7 +264,7 @@ describe('connection', () => { 'should test chunked rowset', done => { // this operation sends 150 packets, so we need to increase the timeout - const database = getChinookConnection(undefined, { timeout: 60 * 1000 }) + const database = getChinookTlsConnection(undefined, { timeout: 60 * 1000 }) database.sendCommands('TEST ROWSET_CHUNK', (error, results) => { expect(error).toBeNull() expect(results.numberOfRows).toBe(147) @@ -306,7 +306,7 @@ describe('connection', () => { it('should apply short timeout', done => { // this operation sends 150 packets and cannot complete in 20ms - const database = getChinookConnection( + const database = getChinookTlsConnection( error => { if (error) { expect(error).toBeInstanceOf(SQLiteCloudError) diff --git a/test/connection-ws.test.ts b/test/connection-ws.test.ts new file mode 100644 index 0000000..06772eb --- /dev/null +++ b/test/connection-ws.test.ts @@ -0,0 +1,477 @@ +/** + * connection-ws.test.ts - test connection via socket.io based gateway + */ + +import { SQLiteCloudError } from '../src/index' +import { SQLiteCloudConnection, anonimizeCommand } from '../src/connection' +import { parseConnectionString } from '../src/utilities' +import { + // + CHINOOK_DATABASE_URL, + LONG_TIMEOUT, + getChinookConfig, + getChinookWebsocketConnection, + WARN_SPEED_MS, + EXPECT_SPEED_MS +} from './shared' + +describe('connection-ws', () => { + let chinook: SQLiteCloudConnection + + beforeEach(() => { + chinook = getChinookWebsocketConnection() + }) + + afterEach(() => { + chinook?.close() + // @ts-ignore + chinook = undefined + }) + + describe('connect', () => { + it('should connect', () => { + // ...in beforeEach + }) + + it('should connect with config object string', done => { + const configObj = getChinookConfig() + configObj.useWebsocket = true + const connection = new SQLiteCloudConnection(configObj) + expect(connection).toBeDefined() + connection.sendCommands('TEST STRING', (error, results) => { + connection.close() + expect(connection.connected).toBe(false) + done() + }) + }) + + it('should not connect with incorrect credentials', done => { + const configObj = getChinookConfig() + configObj.connectionString?.replace(configObj.password as string, 'wrongpassword') + configObj.password = 'wrongpassword' + configObj.useWebsocket = true + + // should attemp connection and return error + const connection = new SQLiteCloudConnection(configObj) + expect(connection).toBeDefined() + connection.sendCommands('TEST STRING', (error, results) => { + expect(error).toBeDefined() + expect(error).toBeInstanceOf(SQLiteCloudError) + expect((error as any).message).toBe('SQLiteCloudError: Authentication failed.') + + connection.close() + expect(connection.connected).toBe(false) + done() + }) + }) + + it('should connect with connection string', done => { + if (CHINOOK_DATABASE_URL.indexOf('localhost') > 0) { + // skip this test when running locally since it requires a self-signed certificate + done() + } + + const conn = new SQLiteCloudConnection(CHINOOK_DATABASE_URL, error => { + expect(error).toBeNull() + expect(conn.connected).toBe(true) + + chinook.sendCommands('TEST STRING', (error, results) => { + conn.close() + expect(conn.connected).toBe(false) + done() + }) + }) + expect(conn).toBeDefined() + }) + }) + + describe('send test commands', () => { + it('should test integer', done => { + chinook.sendCommands('TEST INTEGER', (error, results) => { + expect(error).toBeNull() + expect(results).toBe(123456) + done() + }) + }) + + it('should test null', done => { + chinook.sendCommands('TEST NULL', (error, results) => { + expect(error).toBeNull() + expect(results).toBeNull() + done() + }) + }) + + it('should test float', done => { + chinook.sendCommands('TEST FLOAT', (error, results) => { + expect(error).toBeNull() + expect(results).toBe(3.1415926) + done() + }) + }) + + it('should test string', done => { + chinook.sendCommands('TEST STRING', (error, results) => { + expect(error).toBeNull() + expect(results).toBe('Hello World, this is a test string.') + done() + }) + }) + + it('should test zero string', done => { + chinook.sendCommands('TEST ZERO_STRING', (error, results) => { + expect(error).toBeNull() + expect(results).toBe('Hello World, this is a zero-terminated test string.') + done() + }) + }) + + it('should test string0', done => { + chinook.sendCommands('TEST STRING0', (error, results) => { + expect(error).toBeNull() + expect(results).toBe('') + done() + }) + }) + + it('should test command', done => { + chinook.sendCommands('TEST COMMAND', (error, results) => { + expect(error).toBeNull() + expect(results).toBe('PING') + done() + }) + }) + + it('should test json', done => { + chinook.sendCommands('TEST JSON', (error, results) => { + expect(error).toBeNull() + expect(results).toEqual({ + 'msg-from': { class: 'soldier', name: 'Wixilav' }, + 'msg-to': { class: 'supreme-commander', name: '[Redacted]' }, + 'msg-type': ['0xdeadbeef', 'irc log'], + 'msg-log': [ + 'soldier: Boss there is a slight problem with the piece offering to humans', + 'supreme-commander: Explain yourself soldier!', + "soldier: Well they don't seem to move anymore...", + 'supreme-commander: Oh snap, I came here to see them twerk!' + ] + }) + done() + }) + }) + + it('should test blob', done => { + chinook.sendCommands('TEST BLOB', (error, results) => { + expect(error).toBeNull() + expect(typeof results).toBe('object') + expect(results).toBeInstanceOf(Buffer) + const bufferrowset = results as any as Buffer + expect(bufferrowset.length).toBe(1000) + done() + }) + }) + + it('should test blob0', done => { + chinook.sendCommands('TEST BLOB0', (error, results) => { + expect(error).toBeNull() + expect(typeof results).toBe('object') + expect(results).toBeInstanceOf(Buffer) + const bufferrowset = results as any as Buffer + expect(bufferrowset.length).toBe(0) + done() + }) + }) + + it('should test error', done => { + chinook.sendCommands('TEST ERROR', (error, results) => { + expect(error).toBeDefined() + expect(error).toBeInstanceOf(SQLiteCloudError) + + const sqliteCloudError = error as SQLiteCloudError + expect(sqliteCloudError.message).toBe('This is a test error message with a devil error code.') + expect(sqliteCloudError.errorCode).toBe('66666') + expect(sqliteCloudError.externalErrorCode).toBe('0') + expect(sqliteCloudError.offsetCode).toBe(-1) + + done() + }) + }) + + it('should test exterror', done => { + chinook.sendCommands('TEST EXTERROR', (error, results) => { + expect(error).toBeDefined() + expect(error).toBeInstanceOf(SQLiteCloudError) + + const sqliteCloudError = error as SQLiteCloudError + expect(sqliteCloudError.message).toBe('This is a test error message with an extcode and a devil error code.') + expect(sqliteCloudError.errorCode).toBe('66666') + expect(sqliteCloudError.externalErrorCode).toBe('333') + expect(sqliteCloudError.offsetCode).toBe(-1) + + done() + }) + }) + + it('should test array', done => { + chinook.sendCommands('TEST ARRAY', (error, results) => { + expect(error).toBeNull() + expect(Array.isArray(results)).toBe(true) + + const arrayrowset = results as any as Array + expect(arrayrowset.length).toBe(5) + expect(arrayrowset[0]).toBe('Hello World') + expect(arrayrowset[1]).toBe(123456) + expect(arrayrowset[2]).toBe(3.1415) + expect(arrayrowset[3]).toBeNull() + done() + }) + }) + + it('should test rowset', done => { + chinook.sendCommands('TEST ROWSET', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfRows).toBe(41) + expect(results.numberOfColumns).toBe(2) + expect(results.version == 1 || results.version == 2).toBeTruthy() + expect(results.columnsNames).toEqual(['key', 'value']) + done() + }) + }) + + it( + 'should test chunked rowset', + done => { + // this operation sends 150 packets, so we need to increase the timeout + const database = getChinookWebsocketConnection(undefined, { timeout: 60 * 1000 }) + database.sendCommands('TEST ROWSET_CHUNK', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfRows).toBe(147) + expect(results.numberOfColumns).toBe(1) + expect(results.columnsNames).toEqual(['key']) + + database.close() + done() + }) + }, + LONG_TIMEOUT + ) + }) + + describe('operations', () => { + it( + 'should serialize operations', + done => { + const numQueries = 20 + let completed = 0 + + for (let i = 0; i < numQueries; i++) { + chinook.sendCommands(`select ${i} as "count", 'hello' as 'string'`, (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(2) + expect(results.numberOfRows).toBe(1) + expect(results.version == 1 || results.version == 2).toBeTruthy() + expect(results.columnsNames).toEqual(['count', 'string']) + expect(results.getItem(0, 0)).toBe(i) + + if (++completed >= numQueries) { + done() + } + }) + } + }, + LONG_TIMEOUT + ) +/* TODO RESTORE TEST + it('should apply short timeout', done => { + // apply shorter timeout + const configObj = parseConnectionString(CHINOOK_DATABASE_URL + '?timeout=20') + configObj.websocketOptions = { useWebsocket: true } + const database = new SQLiteCloudConnection(configObj, error => { + if (error) { + expect(error).toBeInstanceOf(SQLiteCloudError) + expect((error as any).message).toBe('Request timed out') + done() + database.close() + } else { + // this operation sends 150 packets and cannot complete in 20ms + database.sendCommands('TEST ROWSET_CHUNK', (error, results) => { + expect(error).toBeInstanceOf(SQLiteCloudError) + expect((error as any).message).toBe('Request timed out') + done() + database.close() + }) + } + }) + }) + }) +*/ + describe('send select commands', () => { + it('should LIST METADATA', done => { + chinook.sendCommands('LIST METADATA;', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(8) + expect(results.numberOfRows).toBe(64) + done() + }) + }) + + it('should select results with no colum names', done => { + chinook.sendCommands("select 42, 'hello'", (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(2) + expect(results.numberOfRows).toBe(1) + expect(results.version == 1 || results.version == 2).toBeTruthy() + expect(results.columnsNames).toEqual(['42', "'hello'"]) // column name should be hello, not 'hello' + expect(results.getItem(0, 0)).toBe(42) + expect(results.getItem(0, 1)).toBe('hello') + + done() + }) + }) + + it('should select long formatted string', done => { + chinook.sendCommands("USE DATABASE :memory:; select printf('%.*c', 1000, 'x') AS DDD", (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(1) + expect(results.numberOfRows).toBe(1) + expect(results.version == 1 || results.version == 2).toBeTruthy() + + const stringrowset = results.getItem(0, 0) as string + expect(stringrowset.startsWith('xxxxxxxxxxxxx')).toBeTruthy() + expect(stringrowset).toHaveLength(1000) + + done() + }) + }) + + it('should select database', done => { + chinook.sendCommands('USE DATABASE chinook.db;', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBeUndefined() + expect(results.numberOfRows).toBeUndefined() + expect(results.version).toBeUndefined() + done() + }) + }) + + it('should select * from tracks limit 10 (no chunks)', done => { + chinook.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(9) + expect(results.numberOfRows).toBe(10) + done() + }) + }) + + it('should select * from tracks (with chunks)', done => { + chinook.sendCommands('SELECT * FROM tracks;', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(9) + expect(results.numberOfRows).toBe(3503) + done() + }) + }) + + it('should select * from albums', done => { + chinook.sendCommands('SELECT * FROM albums;', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(3) + expect(results.numberOfRows).toBe(347) + expect(results.version == 1 || results.version == 2).toBeTruthy() + done() + }) + }) + }) + + describe('connection stress testing', () => { + it( + '20x test string', + done => { + const numQueries = 20 + let completed = 0 + const startTime = Date.now() + for (let i = 0; i < numQueries; i++) { + chinook.sendCommands('TEST STRING', (error, results) => { + expect(error).toBeNull() + expect(results).toBe('Hello World, this is a test string.') + if (++completed >= numQueries) { + const queryMs = (Date.now() - startTime) / numQueries + if (queryMs > WARN_SPEED_MS) { + console.log(`${numQueries}x test string, ${queryMs.toFixed(0)}ms per query`) + expect(queryMs).toBeLessThan(EXPECT_SPEED_MS) + } + done() + } + }) + } + }, + LONG_TIMEOUT + ) + + it( + '20x individual selects', + done => { + const numQueries = 20 + let completed = 0 + const startTime = Date.now() + for (let i = 0; i < numQueries; i++) { + chinook.sendCommands('SELECT * FROM albums ORDER BY RANDOM() LIMIT 4;', (error, results) => { + expect(error).toBeNull() + expect(results.numberOfColumns).toBe(3) + expect(results.numberOfRows).toBe(4) + if (++completed >= numQueries) { + const queryMs = (Date.now() - startTime) / numQueries + if (queryMs > WARN_SPEED_MS) { + console.log(`${numQueries}x individual selects, ${queryMs.toFixed(0)}ms per query`) + expect(queryMs).toBeLessThan(EXPECT_SPEED_MS) + } + done() + } + }) + } + }, + LONG_TIMEOUT + ) + + it( + '20x batched selects', + done => { + const numQueries = 20 + let completed = 0 + const startTime = Date.now() + for (let i = 0; i < numQueries; i++) { + chinook.sendCommands( + 'SELECT * FROM albums ORDER BY RANDOM() LIMIT 16; SELECT * FROM albums ORDER BY RANDOM() LIMIT 12; SELECT * FROM albums ORDER BY RANDOM() LIMIT 8; SELECT * FROM albums ORDER BY RANDOM() LIMIT 4;', + (error, results) => { + expect(error).toBeNull() + // server only returns the last rowset? + expect(results.numberOfColumns).toBe(3) + expect(results.numberOfRows).toBe(4) + if (++completed >= numQueries) { + const queryMs = (Date.now() - startTime) / numQueries + if (queryMs > WARN_SPEED_MS) { + console.log(`${numQueries}x batched selects, ${queryMs.toFixed(0)}ms per query`) + expect(queryMs).toBeLessThan(EXPECT_SPEED_MS) + } + done() + } + } + ) + } + }, + LONG_TIMEOUT + ) + }) + + describe('anonimizeCommand', () => { + it('should mask username and password', () => { + const anonimized = anonimizeCommand('+62 AUTH USER admin PASSWORD notreallyapassword; USE DATABASE chinook.db; ') + expect(anonimized).toBe('+62 AUTH USER ****** PASSWORD ******; USE DATABASE chinook.db; ') + }) + + it('should leave other values untouched', () => { + const anonimized = anonimizeCommand('+62 AUTH USER admin SOMETHING notreallyapassword; USE DATABASE chinook.db; ') + expect(anonimized).toBe('+62 AUTH USER ****** SOMETHING notreallyapassword; USE DATABASE chinook.db; ') + }) + }) +}) diff --git a/test/row.test.ts b/test/row.test.ts index 2dc4326..18277de 100644 --- a/test/row.test.ts +++ b/test/row.test.ts @@ -3,11 +3,11 @@ */ import { SQLiteCloudRowset, SQLiteCloudRow } from '../src/index' -import { getChinookConnection } from './shared' +import { getChinookTlsConnection } from './shared' describe('row', () => { it('can be accessed as a dictionary', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) @@ -31,7 +31,7 @@ describe('row', () => { }) it('can be accessed as an array', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) diff --git a/test/rowset.test.ts b/test/rowset.test.ts index 2522820..e89ed6e 100644 --- a/test/rowset.test.ts +++ b/test/rowset.test.ts @@ -3,12 +3,12 @@ */ import { SQLiteCloudRowset, SQLiteCloudRow } from '../src/index' -import { SQLiteCloudConnection } from '../src/connection' -import { CHINOOK_DATABASE_URL, getChinookConnection, getChinookConfig } from './shared' +import { SQLiteCloudConnection } from '../src/' +import { CHINOOK_DATABASE_URL, getChinookTlsConnection, getChinookConfig } from './shared' describe('rowset', () => { it('can be accessed as an array', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) expect(rowset.numberOfColumns).toBe(9) @@ -35,7 +35,7 @@ describe('rowset', () => { }) it('implements .map', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) expect(rowset.numberOfColumns).toBe(9) @@ -54,7 +54,7 @@ describe('rowset', () => { }) it('implements .filter', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) expect(rowset.numberOfColumns).toBe(9) @@ -73,7 +73,7 @@ describe('rowset', () => { }) it('implements .reduce', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM invoices;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) @@ -87,7 +87,7 @@ describe('rowset', () => { }) it('can be sliced like an array', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 50;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) expect(rowset.numberOfColumns).toBe(9) @@ -127,7 +127,7 @@ describe('rowset', () => { }) it('contains basic metadata', done => { - const connection = getChinookConnection() + const connection = getChinookTlsConnection() connection.sendCommands('SELECT * FROM tracks LIMIT 10;', (error, rowset) => { expect(rowset).toBeInstanceOf(SQLiteCloudRowset) expect(rowset.metadata.numberOfRows).toBe(10) diff --git a/test/shared.ts b/test/shared.ts index 070a959..ff0fa88 100644 --- a/test/shared.ts +++ b/test/shared.ts @@ -28,8 +28,10 @@ export const SIMULTANEOUS_TEST_SIZE = 150 /** Testing database from .env file */ export const CHINOOK_DATABASE_URL = process.env.CHINOOK_DATABASE_URL as string export const TESTING_DATABASE_URL = process.env.TESTING_DATABASE_URL as string +export const GATEWAY_URL = process.env.GATEWAY_URL as string expect(CHINOOK_DATABASE_URL).toBeDefined() expect(TESTING_DATABASE_URL).toBeDefined() +expect(GATEWAY_URL).toBeDefined() export const SELF_SIGNED_CERTIFICATE = `-----BEGIN CERTIFICATE----- MIID6zCCAtOgAwIBAgIUI0lTm5CfVf3mVP8606CkophcyB4wDQYJKoZIhvcNAQEL @@ -87,7 +89,19 @@ export function getChinookConfig(url = CHINOOK_DATABASE_URL, extraConfig?: Parti return chinookConfig } -export function getChinookConnection(callback?: ResultsCallback, extraConfig?: Partial): SQLiteCloudConnection { +/** Returns connection to chinook via websocket gateway */ +export function getChinookWebsocketConnection(callback?: ResultsCallback, extraConfig?: Partial): SQLiteCloudConnection { + let chinookConfig = getChinookConfig(CHINOOK_DATABASE_URL, extraConfig) + chinookConfig = { + ...chinookConfig, + useWebsocket: true, + gatewayUrl: GATEWAY_URL + } + const chinookConnection = new SQLiteCloudConnection(chinookConfig, callback) + return chinookConnection +} + +export function getChinookTlsConnection(callback?: ResultsCallback, extraConfig?: Partial): SQLiteCloudConnection { const chinookConfig = getChinookConfig(CHINOOK_DATABASE_URL, extraConfig) return new SQLiteCloudConnection(chinookConfig, callback) } diff --git a/test/utilities.test.ts b/test/utilities.test.ts index ce3d4f2..be9d78c 100644 --- a/test/utilities.test.ts +++ b/test/utilities.test.ts @@ -107,6 +107,29 @@ describe('parseConnectionString', () => { }) }) + it('should parse connection string without database or options', () => { + const connectionString = 'sqlitecloud://user:password@host:1234' + const config = parseConnectionString(connectionString) + + expect(config).toEqual({ + username: 'user', + password: 'password', + host: 'host', + port: 1234 + }) + }) + + it('should parse connection string without port', () => { + const connectionString = 'sqlitecloud://user:password@host' + const config = parseConnectionString(connectionString) + + expect(config).toEqual({ + username: 'user', + password: 'password', + host: 'host' + }) + }) + it('should throw SQLiteCloudError if the connection string is invalid', () => { const connectionString = 'not a valid url' diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..e548337 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,38 @@ +const path = require('path') +const packageJson = require('./package.json') + +// production config minimizes the code +const productionConfig = { + mode: 'production', + + // application entry point + entry: './lib/index.js', + + // Output configuration + output: { + path: path.resolve(__dirname, 'lib'), + filename: `sqlitecloud.v${packageJson.version}.js`, + library: 'sqlitecloud', + libraryTarget: 'umd', + globalObject: 'this' + }, + + optimization: { + minimize: true + }, + + // add mock 'tls' module + resolve: { + fallback: { + tls: false // tell Webpack to ignore "tls" + } + } +} + +// development config does not minimize the code +const devConfig = JSON.parse(JSON.stringify(productionConfig)) +devConfig.mode = 'development' +devConfig.optimization.minimize = false +devConfig.output.filename = `sqlitecloud.v${packageJson.version}.dev.js` + +module.exports = [productionConfig, devConfig]