From 7081ebf561353d5d5dc8f8c445b870baa241db54 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:32:26 -0800 Subject: [PATCH 1/6] PKG -- [transport-http] Create SubscriptionManager and subscribe function (#2019) --- package-lock.json | 105 +++--- packages/transport-http/package.json | 14 +- packages/transport-http/src/index.ts | 17 + packages/transport-http/src/sdk-send-http.ts | 16 - .../src/{ => send}/combine-urls.test.ts | 0 .../src/{ => send}/combine-urls.ts | 0 .../connect-subscribe-events.test.ts | 0 .../{ => send}/connect-subscribe-events.ts | 0 .../src/{ => send}/connect-ws.test.ts | 2 +- .../src/{ => send}/connect-ws.ts | 2 +- .../src/{ => send}/http-request.js | 0 .../src/{ => send}/http-request.test.js | 0 .../src/{ => send}/send-execute-script.js | 0 .../{ => send}/send-execute-script.test.js | 0 .../src/{ => send}/send-get-account.js | 0 .../src/{ => send}/send-get-account.test.js | 0 .../src/{ => send}/send-get-block-header.js | 0 .../{ => send}/send-get-block-header.test.js | 0 .../src/{ => send}/send-get-block.js | 0 .../src/{ => send}/send-get-block.test.js | 0 .../src/{ => send}/send-get-collection.js | 0 .../{ => send}/send-get-collection.test.js | 0 .../src/{ => send}/send-get-events.js | 0 .../src/{ => send}/send-get-events.test.js | 0 .../{ => send}/send-get-network-parameters.js | 0 .../send-get-network-parameters.test.js | 0 .../send-get-node-version-info.test.ts | 0 .../{ => send}/send-get-node-version-info.ts | 0 .../{ => send}/send-get-transaction-status.js | 0 .../send-get-transaction-status.test.js | 0 .../src/{ => send}/send-get-transaction.js | 0 .../{ => send}/send-get-transaction.test.js | 0 .../src/{ => send}/send-http.ts | 2 +- .../src/{ => send}/send-ping.test.ts | 0 .../src/{ => send}/send-ping.ts | 0 .../src/{ => send}/send-transaction.js | 0 .../src/{ => send}/send-transaction.test.js | 0 .../transport-http/src/{ => send}/utils.js | 0 .../transport-http/src/subscribe/models.ts | 66 ++++ .../transport-http/src/subscribe/subscribe.ts | 34 ++ .../subscribe/subscription-manager.test.ts | 260 ++++++++++++++ .../src/subscribe/subscription-manager.ts | 317 ++++++++++++++++++ .../src/{ => subscribe}/websocket.ts | 5 +- packages/transport-http/src/transport.ts | 8 + packages/typedefs/src/index.ts | 1 + packages/typedefs/src/sdk-transport/index.ts | 10 + .../typedefs/src/sdk-transport/requests.ts | 85 +++++ .../src/sdk-transport/subscriptions.ts | 37 ++ 48 files changed, 912 insertions(+), 69 deletions(-) create mode 100644 packages/transport-http/src/index.ts delete mode 100644 packages/transport-http/src/sdk-send-http.ts rename packages/transport-http/src/{ => send}/combine-urls.test.ts (100%) rename packages/transport-http/src/{ => send}/combine-urls.ts (100%) rename packages/transport-http/src/{ => send}/connect-subscribe-events.test.ts (100%) rename packages/transport-http/src/{ => send}/connect-subscribe-events.ts (100%) rename packages/transport-http/src/{ => send}/connect-ws.test.ts (99%) rename packages/transport-http/src/{ => send}/connect-ws.ts (98%) rename packages/transport-http/src/{ => send}/http-request.js (100%) rename packages/transport-http/src/{ => send}/http-request.test.js (100%) rename packages/transport-http/src/{ => send}/send-execute-script.js (100%) rename packages/transport-http/src/{ => send}/send-execute-script.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-account.js (100%) rename packages/transport-http/src/{ => send}/send-get-account.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-block-header.js (100%) rename packages/transport-http/src/{ => send}/send-get-block-header.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-block.js (100%) rename packages/transport-http/src/{ => send}/send-get-block.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-collection.js (100%) rename packages/transport-http/src/{ => send}/send-get-collection.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-events.js (100%) rename packages/transport-http/src/{ => send}/send-get-events.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-network-parameters.js (100%) rename packages/transport-http/src/{ => send}/send-get-network-parameters.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-node-version-info.test.ts (100%) rename packages/transport-http/src/{ => send}/send-get-node-version-info.ts (100%) rename packages/transport-http/src/{ => send}/send-get-transaction-status.js (100%) rename packages/transport-http/src/{ => send}/send-get-transaction-status.test.js (100%) rename packages/transport-http/src/{ => send}/send-get-transaction.js (100%) rename packages/transport-http/src/{ => send}/send-get-transaction.test.js (100%) rename packages/transport-http/src/{ => send}/send-http.ts (98%) rename packages/transport-http/src/{ => send}/send-ping.test.ts (100%) rename packages/transport-http/src/{ => send}/send-ping.ts (100%) rename packages/transport-http/src/{ => send}/send-transaction.js (100%) rename packages/transport-http/src/{ => send}/send-transaction.test.js (100%) rename packages/transport-http/src/{ => send}/utils.js (100%) create mode 100644 packages/transport-http/src/subscribe/models.ts create mode 100644 packages/transport-http/src/subscribe/subscribe.ts create mode 100644 packages/transport-http/src/subscribe/subscription-manager.test.ts create mode 100644 packages/transport-http/src/subscribe/subscription-manager.ts rename packages/transport-http/src/{ => subscribe}/websocket.ts (58%) create mode 100644 packages/transport-http/src/transport.ts create mode 100644 packages/typedefs/src/sdk-transport/index.ts create mode 100644 packages/typedefs/src/sdk-transport/requests.ts create mode 100644 packages/typedefs/src/sdk-transport/subscriptions.ts diff --git a/package-lock.json b/package-lock.json index 641745064..066cef7fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18893,6 +18893,16 @@ "node": ">=8" } }, + "node_modules/jest-websocket-mock": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/jest-websocket-mock/-/jest-websocket-mock-2.5.0.tgz", + "integrity": "sha512-a+UJGfowNIWvtIKIQBHoEWIUqRxxQHFx4CXT+R5KxxKBtEQ5rS3pPOV/5299sHzqbmeCzxxY5qE4+yfXePePig==", + "dev": true, + "dependencies": { + "jest-diff": "^29.2.0", + "mock-socket": "^9.3.0" + } + }, "node_modules/jest-worker": { "version": "29.7.0", "dev": true, @@ -21658,6 +21668,15 @@ "ufo": "^1.5.4" } }, + "node_modules/mock-socket": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.3.1.tgz", + "integrity": "sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/modify-values": { "version": "1.0.1", "dev": true, @@ -29023,7 +29042,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -29048,16 +29067,16 @@ }, "packages/fcl": { "name": "@onflow/fcl", - "version": "1.13.0-alpha.6", + "version": "1.13.0-alpha.7", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/config": "1.5.1-alpha.0", - "@onflow/fcl-core": "1.13.0-alpha.4", - "@onflow/fcl-wc": "5.4.1-alpha.4", + "@onflow/fcl-core": "1.13.0-alpha.5", + "@onflow/fcl-wc": "5.5.0-alpha.5", "@onflow/interaction": "0.0.11", "@onflow/rlp": "1.2.3-alpha.0", - "@onflow/sdk": "1.5.4-alpha.1", + "@onflow/sdk": "1.5.4-alpha.2", "@onflow/types": "1.4.1-alpha.0", "@onflow/util-actor": "1.3.4-alpha.0", "@onflow/util-address": "1.2.3-alpha.0", @@ -29074,8 +29093,8 @@ "sha3": "^2.1.4" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", - "@onflow/typedefs": "1.4.0-alpha.1", + "@onflow/fcl-bundle": "1.6.0-alpha.1", + "@onflow/typedefs": "1.4.0-alpha.2", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "@types/node": "^18.19.57", @@ -29088,7 +29107,7 @@ }, "packages/fcl-bundle": { "name": "@onflow/fcl-bundle", - "version": "1.5.1-alpha.0", + "version": "1.6.0-alpha.1", "license": "Apache-2.0", "dependencies": { "@babel/plugin-transform-runtime": "^7.25.7", @@ -29122,7 +29141,7 @@ }, "packages/fcl-core": { "name": "@onflow/fcl-core", - "version": "1.13.0-alpha.4", + "version": "1.13.0-alpha.5", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", @@ -29130,7 +29149,7 @@ "@onflow/config": "1.5.1-alpha.0", "@onflow/interaction": "0.0.11", "@onflow/rlp": "1.2.3-alpha.0", - "@onflow/sdk": "1.5.4-alpha.1", + "@onflow/sdk": "1.5.4-alpha.2", "@onflow/transport-http": "1.10.3-alpha.0", "@onflow/types": "1.4.1-alpha.0", "@onflow/util-actor": "1.3.4-alpha.0", @@ -29144,8 +29163,8 @@ "cross-fetch": "^3.1.8" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", - "@onflow/typedefs": "1.4.0-alpha.1", + "@onflow/fcl-bundle": "1.6.0-alpha.1", + "@onflow/typedefs": "1.4.0-alpha.2", "@types/estree": "^1.0.6", "@types/jest": "^29.5.13", "@types/node": "^18.19.57", @@ -29170,15 +29189,15 @@ }, "packages/fcl-react-native": { "name": "@onflow/fcl-react-native", - "version": "1.9.7-alpha.4", + "version": "1.9.7-alpha.5", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/config": "1.5.1-alpha.0", - "@onflow/fcl-core": "1.13.0-alpha.4", + "@onflow/fcl-core": "1.13.0-alpha.5", "@onflow/interaction": "0.0.11", "@onflow/rlp": "1.2.3-alpha.0", - "@onflow/sdk": "1.5.4-alpha.1", + "@onflow/sdk": "1.5.4-alpha.2", "@onflow/types": "1.4.1-alpha.0", "@onflow/util-actor": "1.3.4-alpha.0", "@onflow/util-address": "1.2.3-alpha.0", @@ -29190,8 +29209,8 @@ "cross-fetch": "^3.1.8" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", - "@onflow/typedefs": "1.4.0-alpha.1", + "@onflow/fcl-bundle": "1.6.0-alpha.1", + "@onflow/typedefs": "1.4.0-alpha.2", "@types/estree": "^1.0.6", "@types/node": "^18.19.57", "eslint": "^8.57.1", @@ -29222,7 +29241,7 @@ }, "packages/fcl-wc": { "name": "@onflow/fcl-wc", - "version": "5.4.1-alpha.4", + "version": "5.5.0-alpha.5", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", @@ -29241,8 +29260,8 @@ "devDependencies": { "@babel/plugin-transform-react-jsx": "^7.25.9", "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", - "@onflow/typedefs": "^1.4.0-alpha.1", + "@onflow/fcl-bundle": "1.6.0-alpha.1", + "@onflow/typedefs": "^1.4.0-alpha.2", "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-plugin-jsdoc": "^46.10.1", @@ -29250,7 +29269,7 @@ "jest-preset-preact": "^4.1.1" }, "peerDependencies": { - "@onflow/fcl-core": "1.13.0-alpha.4" + "@onflow/fcl-core": "1.13.0-alpha.5" } }, "packages/fcl/node_modules/typescript": { @@ -29322,7 +29341,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -29333,14 +29352,14 @@ }, "packages/sdk": { "name": "@onflow/sdk", - "version": "1.5.4-alpha.1", + "version": "1.5.4-alpha.2", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", "@onflow/config": "1.5.1-alpha.0", "@onflow/rlp": "1.2.3-alpha.0", "@onflow/transport-http": "1.10.3-alpha.0", - "@onflow/typedefs": "1.4.0-alpha.1", + "@onflow/typedefs": "1.4.0-alpha.2", "@onflow/util-actor": "1.3.4-alpha.0", "@onflow/util-address": "1.2.3-alpha.0", "@onflow/util-invariant": "1.2.4-alpha.0", @@ -29352,7 +29371,7 @@ "uuid": "^9.0.1" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/uuid": "^9.0.8", "eslint": "^8.57.1", "eslint-plugin-jsdoc": "^46.10.1", @@ -29387,8 +29406,8 @@ "@onflow/util-template": "1.2.3-alpha.0" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", - "@onflow/sdk": "1.5.4-alpha.1", + "@onflow/fcl-bundle": "1.6.0-alpha.1", + "@onflow/sdk": "1.5.4-alpha.2", "jest": "^29.7.0" } }, @@ -29409,22 +29428,24 @@ "ws": "^8.18.0" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@onflow/rlp": "1.2.3-alpha.0", - "@onflow/sdk": "1.5.4-alpha.1", + "@onflow/sdk": "1.5.4-alpha.2", "@onflow/types": "1.4.1-alpha.0", - "jest": "^29.7.0" + "jest": "^29.7.0", + "jest-websocket-mock": "^2.5.0", + "mock-socket": "^9.3.1" } }, "packages/typedefs": { "name": "@onflow/typedefs", - "version": "1.4.0-alpha.1", + "version": "1.4.0-alpha.2", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/node": "^18.19.57", "eslint": "^8.57.1", "eslint-plugin-jsdoc": "^46.10.1", @@ -29455,7 +29476,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -29474,7 +29495,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -29492,7 +29513,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@onflow/types": "1.4.1-alpha.0", "@types/jest": "^29.5.13", "@types/node": "^18.19.57", @@ -29527,7 +29548,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@onflow/types": "1.4.1-alpha.0", "@types/jest": "^29.5.13", "@types/node": "^18.19.57", @@ -29560,7 +29581,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@onflow/types": "1.4.1-alpha.0", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", @@ -29579,7 +29600,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -29605,7 +29626,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -29622,7 +29643,7 @@ "@babel/runtime": "^7.25.7" }, "devDependencies": { - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "jest": "^29.7.0" } }, @@ -29636,7 +29657,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", @@ -29654,7 +29675,7 @@ }, "devDependencies": { "@babel/preset-typescript": "^7.25.7", - "@onflow/fcl-bundle": "1.5.1-alpha.0", + "@onflow/fcl-bundle": "1.6.0-alpha.1", "@types/jest": "^29.5.13", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", diff --git a/packages/transport-http/package.json b/packages/transport-http/package.json index 4cca54899..49a31549e 100644 --- a/packages/transport-http/package.json +++ b/packages/transport-http/package.json @@ -17,13 +17,15 @@ "@onflow/rlp": "1.2.3-alpha.0", "@onflow/sdk": "1.5.4-alpha.2", "@onflow/types": "1.4.1-alpha.0", - "jest": "^29.7.0" + "jest": "^29.7.0", + "jest-websocket-mock": "^2.5.0", + "mock-socket": "^9.3.1" }, - "source": "src/sdk-send-http.ts", - "main": "dist/sdk-send-http.js", - "module": "dist/sdk-send-http.module.js", - "unpkg": "dist/sdk-send-http.umd.js", - "types": "types/sdk-send-http.d.ts", + "source": "src/index.ts", + "main": "dist/index.js", + "module": "dist/index.module.js", + "unpkg": "dist/index.umd.js", + "types": "types/index.d.ts", "scripts": { "alpha": "npm publish --tag alpha", "prepublishOnly": "npm test && npm run build", diff --git a/packages/transport-http/src/index.ts b/packages/transport-http/src/index.ts new file mode 100644 index 000000000..bbabf100b --- /dev/null +++ b/packages/transport-http/src/index.ts @@ -0,0 +1,17 @@ +export {sendExecuteScript} from "./send/send-execute-script" +export {sendGetAccount} from "./send/send-get-account" +export {sendGetBlockHeader} from "./send/send-get-block-header" +export {sendGetBlock} from "./send/send-get-block" +export {sendGetCollection} from "./send/send-get-collection" +export {sendGetEvents} from "./send/send-get-events" +export {sendGetTransaction} from "./send/send-get-transaction" +export {sendGetTransactionStatus} from "./send/send-get-transaction-status" +export {sendPing} from "./send/send-ping" +export {sendTransaction} from "./send/send-transaction" +export {sendGetNetworkParameters} from "./send/send-get-network-parameters" +export {sendGetNodeVersionInfo} from "./send/send-get-node-version-info" +export {connectSubscribeEvents} from "./send/connect-subscribe-events" +export {send} from "./send/send-http" +export {WebsocketError} from "./send/connect-ws" +export {HTTPRequestError} from "./send/http-request.js" +export {httpTransport} from "./transport" diff --git a/packages/transport-http/src/sdk-send-http.ts b/packages/transport-http/src/sdk-send-http.ts deleted file mode 100644 index 08a5f76df..000000000 --- a/packages/transport-http/src/sdk-send-http.ts +++ /dev/null @@ -1,16 +0,0 @@ -export {sendExecuteScript} from "./send-execute-script" -export {sendGetAccount} from "./send-get-account" -export {sendGetBlockHeader} from "./send-get-block-header" -export {sendGetBlock} from "./send-get-block" -export {sendGetCollection} from "./send-get-collection" -export {sendGetEvents} from "./send-get-events" -export {sendGetTransaction} from "./send-get-transaction" -export {sendGetTransactionStatus} from "./send-get-transaction-status" -export {sendPing} from "./send-ping" -export {sendTransaction} from "./send-transaction" -export {sendGetNetworkParameters} from "./send-get-network-parameters" -export {sendGetNodeVersionInfo} from "./send-get-node-version-info" -export {connectSubscribeEvents} from "./connect-subscribe-events" -export {send} from "./send-http" -export {WebsocketError} from "./connect-ws" -export {HTTPRequestError} from "./http-request.js" diff --git a/packages/transport-http/src/combine-urls.test.ts b/packages/transport-http/src/send/combine-urls.test.ts similarity index 100% rename from packages/transport-http/src/combine-urls.test.ts rename to packages/transport-http/src/send/combine-urls.test.ts diff --git a/packages/transport-http/src/combine-urls.ts b/packages/transport-http/src/send/combine-urls.ts similarity index 100% rename from packages/transport-http/src/combine-urls.ts rename to packages/transport-http/src/send/combine-urls.ts diff --git a/packages/transport-http/src/connect-subscribe-events.test.ts b/packages/transport-http/src/send/connect-subscribe-events.test.ts similarity index 100% rename from packages/transport-http/src/connect-subscribe-events.test.ts rename to packages/transport-http/src/send/connect-subscribe-events.test.ts diff --git a/packages/transport-http/src/connect-subscribe-events.ts b/packages/transport-http/src/send/connect-subscribe-events.ts similarity index 100% rename from packages/transport-http/src/connect-subscribe-events.ts rename to packages/transport-http/src/send/connect-subscribe-events.ts diff --git a/packages/transport-http/src/connect-ws.test.ts b/packages/transport-http/src/send/connect-ws.test.ts similarity index 99% rename from packages/transport-http/src/connect-ws.test.ts rename to packages/transport-http/src/send/connect-ws.test.ts index a7218e9a1..c2cc19948 100644 --- a/packages/transport-http/src/connect-ws.test.ts +++ b/packages/transport-http/src/send/connect-ws.test.ts @@ -1,5 +1,5 @@ import {buildConnectionUrl, connectWs} from "./connect-ws" -import * as WebSocketModule from "./websocket" +import * as WebSocketModule from "../subscribe/websocket" describe("connectWs", () => { describe("buildConnectionUrl", () => { diff --git a/packages/transport-http/src/connect-ws.ts b/packages/transport-http/src/send/connect-ws.ts similarity index 98% rename from packages/transport-http/src/connect-ws.ts rename to packages/transport-http/src/send/connect-ws.ts index b9f373791..a58d6c672 100644 --- a/packages/transport-http/src/connect-ws.ts +++ b/packages/transport-http/src/send/connect-ws.ts @@ -1,7 +1,7 @@ import {EventEmitter} from "events" import {safeParseJSON} from "./utils" import {StreamConnection} from "@onflow/typedefs" -import {WebSocket} from "./websocket" +import {WebSocket} from "../subscribe/websocket" export class WebsocketError extends Error { code?: number diff --git a/packages/transport-http/src/http-request.js b/packages/transport-http/src/send/http-request.js similarity index 100% rename from packages/transport-http/src/http-request.js rename to packages/transport-http/src/send/http-request.js diff --git a/packages/transport-http/src/http-request.test.js b/packages/transport-http/src/send/http-request.test.js similarity index 100% rename from packages/transport-http/src/http-request.test.js rename to packages/transport-http/src/send/http-request.test.js diff --git a/packages/transport-http/src/send-execute-script.js b/packages/transport-http/src/send/send-execute-script.js similarity index 100% rename from packages/transport-http/src/send-execute-script.js rename to packages/transport-http/src/send/send-execute-script.js diff --git a/packages/transport-http/src/send-execute-script.test.js b/packages/transport-http/src/send/send-execute-script.test.js similarity index 100% rename from packages/transport-http/src/send-execute-script.test.js rename to packages/transport-http/src/send/send-execute-script.test.js diff --git a/packages/transport-http/src/send-get-account.js b/packages/transport-http/src/send/send-get-account.js similarity index 100% rename from packages/transport-http/src/send-get-account.js rename to packages/transport-http/src/send/send-get-account.js diff --git a/packages/transport-http/src/send-get-account.test.js b/packages/transport-http/src/send/send-get-account.test.js similarity index 100% rename from packages/transport-http/src/send-get-account.test.js rename to packages/transport-http/src/send/send-get-account.test.js diff --git a/packages/transport-http/src/send-get-block-header.js b/packages/transport-http/src/send/send-get-block-header.js similarity index 100% rename from packages/transport-http/src/send-get-block-header.js rename to packages/transport-http/src/send/send-get-block-header.js diff --git a/packages/transport-http/src/send-get-block-header.test.js b/packages/transport-http/src/send/send-get-block-header.test.js similarity index 100% rename from packages/transport-http/src/send-get-block-header.test.js rename to packages/transport-http/src/send/send-get-block-header.test.js diff --git a/packages/transport-http/src/send-get-block.js b/packages/transport-http/src/send/send-get-block.js similarity index 100% rename from packages/transport-http/src/send-get-block.js rename to packages/transport-http/src/send/send-get-block.js diff --git a/packages/transport-http/src/send-get-block.test.js b/packages/transport-http/src/send/send-get-block.test.js similarity index 100% rename from packages/transport-http/src/send-get-block.test.js rename to packages/transport-http/src/send/send-get-block.test.js diff --git a/packages/transport-http/src/send-get-collection.js b/packages/transport-http/src/send/send-get-collection.js similarity index 100% rename from packages/transport-http/src/send-get-collection.js rename to packages/transport-http/src/send/send-get-collection.js diff --git a/packages/transport-http/src/send-get-collection.test.js b/packages/transport-http/src/send/send-get-collection.test.js similarity index 100% rename from packages/transport-http/src/send-get-collection.test.js rename to packages/transport-http/src/send/send-get-collection.test.js diff --git a/packages/transport-http/src/send-get-events.js b/packages/transport-http/src/send/send-get-events.js similarity index 100% rename from packages/transport-http/src/send-get-events.js rename to packages/transport-http/src/send/send-get-events.js diff --git a/packages/transport-http/src/send-get-events.test.js b/packages/transport-http/src/send/send-get-events.test.js similarity index 100% rename from packages/transport-http/src/send-get-events.test.js rename to packages/transport-http/src/send/send-get-events.test.js diff --git a/packages/transport-http/src/send-get-network-parameters.js b/packages/transport-http/src/send/send-get-network-parameters.js similarity index 100% rename from packages/transport-http/src/send-get-network-parameters.js rename to packages/transport-http/src/send/send-get-network-parameters.js diff --git a/packages/transport-http/src/send-get-network-parameters.test.js b/packages/transport-http/src/send/send-get-network-parameters.test.js similarity index 100% rename from packages/transport-http/src/send-get-network-parameters.test.js rename to packages/transport-http/src/send/send-get-network-parameters.test.js diff --git a/packages/transport-http/src/send-get-node-version-info.test.ts b/packages/transport-http/src/send/send-get-node-version-info.test.ts similarity index 100% rename from packages/transport-http/src/send-get-node-version-info.test.ts rename to packages/transport-http/src/send/send-get-node-version-info.test.ts diff --git a/packages/transport-http/src/send-get-node-version-info.ts b/packages/transport-http/src/send/send-get-node-version-info.ts similarity index 100% rename from packages/transport-http/src/send-get-node-version-info.ts rename to packages/transport-http/src/send/send-get-node-version-info.ts diff --git a/packages/transport-http/src/send-get-transaction-status.js b/packages/transport-http/src/send/send-get-transaction-status.js similarity index 100% rename from packages/transport-http/src/send-get-transaction-status.js rename to packages/transport-http/src/send/send-get-transaction-status.js diff --git a/packages/transport-http/src/send-get-transaction-status.test.js b/packages/transport-http/src/send/send-get-transaction-status.test.js similarity index 100% rename from packages/transport-http/src/send-get-transaction-status.test.js rename to packages/transport-http/src/send/send-get-transaction-status.test.js diff --git a/packages/transport-http/src/send-get-transaction.js b/packages/transport-http/src/send/send-get-transaction.js similarity index 100% rename from packages/transport-http/src/send-get-transaction.js rename to packages/transport-http/src/send/send-get-transaction.js diff --git a/packages/transport-http/src/send-get-transaction.test.js b/packages/transport-http/src/send/send-get-transaction.test.js similarity index 100% rename from packages/transport-http/src/send-get-transaction.test.js rename to packages/transport-http/src/send/send-get-transaction.test.js diff --git a/packages/transport-http/src/send-http.ts b/packages/transport-http/src/send/send-http.ts similarity index 98% rename from packages/transport-http/src/send-http.ts rename to packages/transport-http/src/send/send-http.ts index 3ea87d3f9..7589a7883 100644 --- a/packages/transport-http/src/send-http.ts +++ b/packages/transport-http/src/send/send-http.ts @@ -9,7 +9,7 @@ import {connectSubscribeEvents} from "./connect-subscribe-events.js" import {sendGetBlock} from "./send-get-block.js" import {sendGetBlockHeader} from "./send-get-block-header.js" import {sendGetCollection} from "./send-get-collection.js" -import {sendPing, ISendPingContext} from "./send-ping" +import {sendPing, ISendPingContext} from "./send-ping.js" import {sendGetNetworkParameters} from "./send-get-network-parameters.js" import {Interaction} from "@onflow/typedefs" import {sendGetNodeVersionInfo} from "./send-get-node-version-info.js" diff --git a/packages/transport-http/src/send-ping.test.ts b/packages/transport-http/src/send/send-ping.test.ts similarity index 100% rename from packages/transport-http/src/send-ping.test.ts rename to packages/transport-http/src/send/send-ping.test.ts diff --git a/packages/transport-http/src/send-ping.ts b/packages/transport-http/src/send/send-ping.ts similarity index 100% rename from packages/transport-http/src/send-ping.ts rename to packages/transport-http/src/send/send-ping.ts diff --git a/packages/transport-http/src/send-transaction.js b/packages/transport-http/src/send/send-transaction.js similarity index 100% rename from packages/transport-http/src/send-transaction.js rename to packages/transport-http/src/send/send-transaction.js diff --git a/packages/transport-http/src/send-transaction.test.js b/packages/transport-http/src/send/send-transaction.test.js similarity index 100% rename from packages/transport-http/src/send-transaction.test.js rename to packages/transport-http/src/send/send-transaction.test.js diff --git a/packages/transport-http/src/utils.js b/packages/transport-http/src/send/utils.js similarity index 100% rename from packages/transport-http/src/utils.js rename to packages/transport-http/src/send/utils.js diff --git a/packages/transport-http/src/subscribe/models.ts b/packages/transport-http/src/subscribe/models.ts new file mode 100644 index 000000000..48d6aad07 --- /dev/null +++ b/packages/transport-http/src/subscribe/models.ts @@ -0,0 +1,66 @@ +export enum Action { + LIST_SUBSCRIPTIONS = "list_subscriptions", + SUBSCRIBE = "subscribe", + UNSUBSCRIBE = "unsubscribe", +} +export interface BaseMessageRequest { + action: Action +} + +export interface BaseMessageResponse { + action?: Action + success: boolean + error_message?: string +} + +export interface ListSubscriptionsMessageRequest extends BaseMessageRequest { + action: Action.LIST_SUBSCRIPTIONS +} + +export interface ListSubscriptionsMessageResponse extends BaseMessageResponse { + action: Action.LIST_SUBSCRIPTIONS + subscriptions?: SubscriptionEntry[] +} + +export interface SubscribeMessageRequest extends BaseMessageRequest { + action: Action.SUBSCRIBE + topic: string + arguments: Record +} + +export interface SubscribeMessageResponse extends BaseMessageResponse { + action: Action.SUBSCRIBE + topic: string + id: string +} + +export interface UnsubscribeMessageRequest extends BaseMessageRequest { + action: Action.UNSUBSCRIBE + id: string +} + +export type UnsubscribeMessageResponse = BaseMessageResponse & { + action: Action.UNSUBSCRIBE + id: string +} + +export type SubscriptionEntry = { + id: string + topic: string + arguments: Record +} + +export type MessageRequest = + | ListSubscriptionsMessageRequest + | SubscribeMessageRequest + | UnsubscribeMessageRequest + +export type MessageResponse = + | ListSubscriptionsMessageResponse + | SubscribeMessageResponse + | UnsubscribeMessageResponse + +export type SubscriptionDataMessage = { + id: string + data: any +} diff --git a/packages/transport-http/src/subscribe/subscribe.ts b/packages/transport-http/src/subscribe/subscribe.ts new file mode 100644 index 000000000..50e3ae3d4 --- /dev/null +++ b/packages/transport-http/src/subscribe/subscribe.ts @@ -0,0 +1,34 @@ +import {SdkTransport} from "@onflow/typedefs" +import {SubscriptionManager} from "./subscription-manager" + +// Map of SubscriptionManager instances by access node URL +let subscriptionManagerMap: Map = new Map() + +export async function subscribe( + { + topic, + args, + onData, + onError, + }: { + topic: T + args: SdkTransport.SubscriptionArguments + onData: (data: SdkTransport.SubscriptionData) => void + onError: (error: Error) => void + }, + opts: {node: string} +): Promise { + const manager = + subscriptionManagerMap.get(opts.node) || + new SubscriptionManager({ + node: opts.node, + }) + subscriptionManagerMap.set(opts.node, manager) + + return manager.subscribe({ + topic, + args, + onData, + onError, + }) +} diff --git a/packages/transport-http/src/subscribe/subscription-manager.test.ts b/packages/transport-http/src/subscribe/subscription-manager.test.ts new file mode 100644 index 000000000..48d2b81cc --- /dev/null +++ b/packages/transport-http/src/subscribe/subscription-manager.test.ts @@ -0,0 +1,260 @@ +import WS from "jest-websocket-mock" +import {WebSocket as mockSocket} from "mock-socket" +import { + Action, + SubscribeMessageRequest, + SubscribeMessageResponse, + SubscriptionDataMessage, + UnsubscribeMessageRequest, +} from "./models" +import { + SubscriptionManager, + SubscriptionManagerConfig, +} from "./subscription-manager" +import {SdkTransport} from "@onflow/typedefs" + +jest.mock("./websocket", () => ({ + WebSocket: mockSocket, +})) + +describe("WsSubscriptionTransport", () => { + let mockWs: WS + beforeEach(() => { + mockWs = new WS("wss://localhost:8080") + }) + + afterEach(() => { + WS.clean() + }) + + test("does not connect to the socket when no subscriptions are made", async () => { + const config: SubscriptionManagerConfig = { + node: "wss://localhost:8080", + } + + new SubscriptionManager(config) + + await new Promise(resolve => setTimeout(resolve, 0)) + expect(mockWs.server.clients).toHaveLength(0) + }) + + test("disconnects from the socket when the last subscription is removed", async () => { + const config: SubscriptionManagerConfig = { + node: "wss://localhost:8080", + } + const streamController = new SubscriptionManager(config) + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {key: "value"} as any + const onData = jest.fn() + const onError = jest.fn() + + let serverPromise = (async () => { + await mockWs.connected + + const msg = (await mockWs.nextMessage) as string + const data = JSON.parse(msg) as SubscribeMessageRequest + expect(data).toEqual({ + action: "subscribe", + topic, + arguments: args, + }) + + const response: SubscribeMessageResponse = { + id: "id", + action: Action.SUBSCRIBE, + success: true, + topic, + } + mockWs.send(JSON.stringify(response)) + })() + + const [subscription] = await Promise.all([ + streamController.subscribe({ + topic, + args, + onData, + onError, + }), + serverPromise, + ]) + + expect(subscription).toBeDefined() + expect(subscription.unsubscribe).toBeInstanceOf(Function) + + subscription.unsubscribe() + await new Promise(resolve => setTimeout(resolve, 0)) + + await mockWs.closed + expect(mockWs.server.clients).toHaveLength(0) + }) + + test("subscribes, receives data, and unsubscribes", async () => { + const config: SubscriptionManagerConfig = { + node: "wss://localhost:8080", + } + const streamController = new SubscriptionManager(config) + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {key: "value"} as any + const onData = jest.fn() + const onError = jest.fn() + + let serverPromise = (async () => { + await mockWs.connected + + const msg = (await mockWs.nextMessage) as string + const data = JSON.parse(msg) as SubscribeMessageRequest + expect(data).toEqual({ + action: "subscribe", + topic, + arguments: args, + }) + + const response: SubscribeMessageResponse = { + id: "id", + action: Action.SUBSCRIBE, + success: true, + topic, + } + mockWs.send(JSON.stringify(response)) + })() + + const [subscription] = await Promise.all([ + streamController.subscribe({ + topic, + args, + onData, + onError, + }), + serverPromise, + ]) + + expect(subscription).toBeDefined() + expect(subscription.unsubscribe).toBeInstanceOf(Function) + + serverPromise = (async () => { + const data = { + id: "id", + data: {key: "value"}, + } as SubscriptionDataMessage + mockWs.send(JSON.stringify(data)) + })() + + await serverPromise + + expect(onData).toHaveBeenCalledTimes(1) + expect(onData).toHaveBeenCalledWith({key: "value"}) + expect(onError).toHaveBeenCalledTimes(0) + + serverPromise = (async () => { + const msg = (await mockWs.nextMessage) as string + const data = JSON.parse(msg) as UnsubscribeMessageRequest + expect(data).toEqual({ + action: "unsubscribe", + id: "id", + }) + })() + + subscription.unsubscribe() + await serverPromise + }) + + test("reconnects to stream on close", async () => { + const config: SubscriptionManagerConfig = { + node: "wss://localhost:8080", + } + const streamController = new SubscriptionManager(config) + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {key: "value"} as any + const onData = jest.fn() + const onError = jest.fn() + + let serverPromise = (async () => { + await mockWs.connected + + const msg = (await mockWs.nextMessage) as string + const data = JSON.parse(msg) as SubscribeMessageRequest + expect(data).toEqual({ + action: "subscribe", + topic, + arguments: args, + }) + + const response: SubscribeMessageResponse = { + id: "id1", + action: Action.SUBSCRIBE, + success: true, + topic, + } + mockWs.send(JSON.stringify(response)) + })() + + const [subscription] = await Promise.all([ + streamController.subscribe({ + topic, + args, + onData, + onError, + }), + serverPromise, + ]) + + expect(subscription).toBeDefined() + expect(subscription.unsubscribe).toBeInstanceOf(Function) + + serverPromise = (async () => { + const data = { + id: "id1", + data: {key: "value"}, + } as SubscriptionDataMessage + mockWs.send(JSON.stringify(data)) + })() + + await serverPromise + + expect(onData).toHaveBeenCalledTimes(1) + expect(onData).toHaveBeenCalledWith({key: "value"}) + expect(onError).toHaveBeenCalledTimes(0) + + // Close the connection and create a new one + mockWs.close() + mockWs = new WS("wss://localhost:8080") + + serverPromise = (async () => { + await mockWs.connected + + const msg = (await mockWs.nextMessage) as string + const data = JSON.parse(msg) as SubscribeMessageRequest + expect(data).toEqual({ + action: "subscribe", + topic, + arguments: args, + }) + + const response: SubscribeMessageResponse = { + id: "id2", + action: Action.SUBSCRIBE, + success: true, + topic, + } + mockWs.send(JSON.stringify(response)) + })() + + await serverPromise + + // Wait for client to register the new subscription + await new Promise(resolve => setTimeout(resolve, 0)) + + serverPromise = (async () => { + const data = { + id: "id2", + data: {key: "value2"}, + } as SubscriptionDataMessage + mockWs.send(JSON.stringify(data)) + })() + + await serverPromise + + expect(onData).toHaveBeenCalledTimes(2) + expect(onData.mock.calls[1]).toEqual([{key: "value2"}]) + }) +}) diff --git a/packages/transport-http/src/subscribe/subscription-manager.ts b/packages/transport-http/src/subscribe/subscription-manager.ts new file mode 100644 index 000000000..b7ea02436 --- /dev/null +++ b/packages/transport-http/src/subscribe/subscription-manager.ts @@ -0,0 +1,317 @@ +import { + Action, + MessageResponse, + SubscriptionDataMessage, + UnsubscribeMessageResponse, +} from "./models" +import { + SubscribeMessageRequest, + SubscribeMessageResponse, + UnsubscribeMessageRequest, +} from "./models" +import type {SdkTransport} from "@onflow/typedefs" +import {WebSocket} from "./websocket" +import * as logger from "@onflow/util-logger" + +const WS_OPEN = 1 + +type DeepRequired = Required<{ + [K in keyof T]: DeepRequired +}> + +interface SubscriptionInfo { + // Internal ID for the subscription + id: number + // Remote ID assigned by the server used for message routing and unsubscribing + remoteId?: string + // The topic of the subscription + topic: T + // The checkpoint to resume the subscription from + checkpoint: SdkTransport.SubscriptionArguments + // The callback to call when a data is received + onData: (data: any) => void + // The callback to call when an error occurs + onError: (error: Error) => void +} + +export interface SubscriptionManagerConfig { + /** + * The URL of the node to connect to + */ + node: string + /** + * Options for reconnecting to the server + */ + reconnectOptions?: { + /** + * The initial delay in milliseconds before reconnecting + * @default 500 + */ + initialReconnectDelay?: number + /** + * The maximum number of reconnection attempts + * @default 5 + */ + reconnectAttempts?: number + /** + * The maximum delay in milliseconds between reconnection attempts + * @default 5000 + */ + maxReconnectDelay?: number + } +} + +export class SubscriptionManager { + private counter = 0 + private subscriptions: SubscriptionInfo[] = [] + private socket: WebSocket | null = null + private config: DeepRequired + private reconnectAttempts = 0 + + constructor(config: SubscriptionManagerConfig) { + this.config = { + ...config, + reconnectOptions: { + initialReconnectDelay: 500, + reconnectAttempts: 5, + maxReconnectDelay: 5000, + ...config.reconnectOptions, + }, + } + } + + // Lazy connect to the socket when the first subscription is made + private async connect() { + return new Promise((resolve, reject) => { + // If the socket is already open, do nothing + if (this.socket?.readyState === WS_OPEN) { + return + } + + this.socket = new WebSocket(this.config.node) + this.socket.onmessage = event => { + const data = JSON.parse(event.data) as + | MessageResponse + | SubscriptionDataMessage + + if ("action" in data) { + // TODO, waiting for AN team to decide what to do here + } else { + const sub = this.subscriptions.find(sub => sub.remoteId === data.id) + if (!sub) return + + // Update the block height to checkpoint for disconnects + this.updateSubscriptionCheckpoint(sub, data) + + // Call the subscription callback + sub.onData(data.data) + } + } + this.socket.onclose = () => { + void this.reconnect() + } + this.socket.onerror = e => { + this.reconnect(e) + } + + this.socket.onopen = () => { + // Restore subscriptions + Promise.all( + this.subscriptions.map(async sub => { + const response = await this.sendSubscribe(sub) + sub.remoteId = response.id + }) + ) + .then(() => { + resolve() + }) + .catch(e => { + reject(new Error(`Failed to restore subscriptions: ${e}`)) + }) + } + }) + } + + private async reconnect(error?: any) { + // Clear the socket + this.socket = null + + // If there are no subscriptions, do nothing + if (this.subscriptions.length === 0) { + return + } + + // Clear all remote ids + this.subscriptions.forEach(sub => { + delete sub.remoteId + }) + + // Validate the number of reconnection attempts + if ( + this.reconnectAttempts >= this.config.reconnectOptions.reconnectAttempts + ) { + logger.log({ + level: logger.LEVELS.error, + title: "WebSocket Error", + message: `Failed to reconnect to the server after ${this.reconnectAttempts + 1} attempts: ${error}`, + }) + + this.subscriptions.forEach(sub => { + sub.onError( + new Error( + `Failed to reconnect to the server after ${this.reconnectAttempts + 1} attempts: ${error}` + ) + ) + }) + this.subscriptions = [] + this.reconnectAttempts = 0 + } else { + logger.log({ + level: logger.LEVELS.warn, + title: "WebSocket Error", + message: `WebSocket error, reconnecting in ${this.backoffInterval}ms: ${error}`, + }) + + // Delay the reconnection + await new Promise(resolve => setTimeout(resolve, this.backoffInterval)) + + // Try to reconnect + this.reconnectAttempts++ + await this.connect() + this.reconnectAttempts = 0 + } + } + + async subscribe(opts: { + topic: T + args: SdkTransport.SubscriptionArguments + onData: (data: SdkTransport.SubscriptionData) => void + onError: (error: Error) => void + }): Promise { + // Connect the socket if it's not already open + await this.connect() + + // Track the subscription locally + const sub: SubscriptionInfo = { + id: this.counter++, + topic: opts.topic, + checkpoint: opts.args, + onData: opts.onData, + onError: opts.onError, + } + this.subscriptions.push(sub) + + // Send the subscribe message + const response = await this.sendSubscribe(sub) + + if (!response.success) { + throw new Error( + `Failed to subscribe to topic ${sub.topic}, error message: ${response.error_message}` + ) + } + + // Update the subscription with the remote id + sub.remoteId = response.id + + return { + unsubscribe: () => this.unsubscribe(sub.id), + } + } + + private unsubscribe(id: number): void { + // Get the subscription + const sub = this.subscriptions.find(sub => sub.id === id) + if (!sub) return + + // Send the unsubscribe message + this.sendUnsubscribe(sub).catch(e => { + console.error( + `Failed to unsubscribe from topic ${sub.topic}, error: ${e}` + ) + }) + + // Remove the subscription + this.subscriptions = this.subscriptions.filter(sub => sub.id !== id) + + // Close the socket if there are no more subscriptions + if (this.subscriptions.length === 0) { + this.socket?.close() + } + } + + private async sendSubscribe( + sub: SubscriptionInfo + ) { + // Send the subscription message + const request: SubscribeMessageRequest = { + action: Action.SUBSCRIBE, + topic: sub.topic, + arguments: sub.checkpoint, + } + this.socket?.send(JSON.stringify(request)) + + const response: SubscribeMessageResponse = await this.waitForResponse() + + if (!response.success) { + throw new Error( + `Failed to subscribe to topic ${sub.topic}, error message: ${response.error_message}` + ) + } + + return response + } + + private async sendUnsubscribe( + sub: SubscriptionInfo + ) { + // Send the unsubscribe message if the subscription has a remote id + const {remoteId} = sub + if (remoteId) { + const request: UnsubscribeMessageRequest = { + action: Action.UNSUBSCRIBE, + id: remoteId, + } + this.socket?.send(JSON.stringify(request)) + + const response: UnsubscribeMessageResponse = await this.waitForResponse() + + if (!response.success) { + throw new Error( + `Failed to unsubscribe from topic ${sub.topic}, error message: ${response.error_message}` + ) + } + } + } + + private async waitForResponse(): Promise { + // TODO: NOOP, waiting for AN team to decide what to do here, this is a placeholder + return new Promise(resolve => { + this.socket?.addEventListener("message", event => { + const data = JSON.parse(event.data) as T + if (data.action) { + resolve(data) + } + }) + }) + } + + // Update the subscription checkpoint when a message is received + // These checkpoints are used to resume subscriptions after disconnects + private updateSubscriptionCheckpoint< + T extends SdkTransport.SubscriptionTopic = SdkTransport.SubscriptionTopic, + >(sub: SubscriptionInfo, message: SubscriptionDataMessage) { + // TODO: Will be implemented with each subscription topic + } + + /** + * Calculate the backoff interval for reconnection attempts + * @returns The backoff interval in milliseconds + */ + private get backoffInterval() { + return Math.min( + this.config.reconnectOptions.maxReconnectDelay, + this.config.reconnectOptions.initialReconnectDelay * + 2 ** this.reconnectAttempts + ) + } +} diff --git a/packages/transport-http/src/websocket.ts b/packages/transport-http/src/subscribe/websocket.ts similarity index 58% rename from packages/transport-http/src/websocket.ts rename to packages/transport-http/src/subscribe/websocket.ts index 370efcede..996c99553 100644 --- a/packages/transport-http/src/websocket.ts +++ b/packages/transport-http/src/subscribe/websocket.ts @@ -1,6 +1,7 @@ import _WebSocket from "isomorphic-ws" -export const WebSocket = _WebSocket as new ( +export const WebSocket = _WebSocket as (new ( url: string | URL, protocols?: string | string[] | undefined -) => WebSocket +) => WebSocket) & + WebSocket diff --git a/packages/transport-http/src/transport.ts b/packages/transport-http/src/transport.ts new file mode 100644 index 000000000..c0484d062 --- /dev/null +++ b/packages/transport-http/src/transport.ts @@ -0,0 +1,8 @@ +import {SdkTransport} from "@onflow/typedefs" +import {send} from "./send/send-http" +import {subscribe} from "./subscribe/subscribe" + +export const httpTransport: SdkTransport.Transport = { + send, + subscribe, +} diff --git a/packages/typedefs/src/index.ts b/packages/typedefs/src/index.ts index 4f4082674..b9d516de3 100644 --- a/packages/typedefs/src/index.ts +++ b/packages/typedefs/src/index.ts @@ -459,3 +459,4 @@ export type EventStream = StreamConnection<{ export * from "./interaction" export * from "./fvm-errors" +export * as SdkTransport from "./sdk-transport" diff --git a/packages/typedefs/src/sdk-transport/index.ts b/packages/typedefs/src/sdk-transport/index.ts new file mode 100644 index 000000000..915962252 --- /dev/null +++ b/packages/typedefs/src/sdk-transport/index.ts @@ -0,0 +1,10 @@ +import {SendFn} from "./requests" +import {SubscribeFn} from "./subscriptions" + +export type Transport = { + send: SendFn + subscribe: SubscribeFn +} + +export * from "./subscriptions" +export * from "./requests" diff --git a/packages/typedefs/src/sdk-transport/requests.ts b/packages/typedefs/src/sdk-transport/requests.ts new file mode 100644 index 000000000..07a0b0665 --- /dev/null +++ b/packages/typedefs/src/sdk-transport/requests.ts @@ -0,0 +1,85 @@ +import {Interaction} from "../interaction" + +interface InteractionModule { + isTransaction: (ix: Interaction) => boolean + isGetTransactionStatus: (ix: Interaction) => boolean + isGetTransaction: (ix: Interaction) => boolean + isScript: (ix: Interaction) => boolean + isGetAccount: (ix: Interaction) => boolean + isGetEvents: (ix: Interaction) => boolean + isGetBlock: (ix: Interaction) => boolean + isGetBlockHeader: (ix: Interaction) => boolean + isGetCollection: (ix: Interaction) => boolean + isPing: (ix: Interaction) => boolean + isGetNetworkParameters: (ix: Interaction) => boolean + isSubscribeEvents?: (ix: Interaction) => boolean + isGetNodeVersionInfo?: (ix: Interaction) => boolean +} +interface IContext { + ix: InteractionModule +} +interface IOptsCommon { + node?: string +} + +interface IOpts extends IOptsCommon { + sendTransaction?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetTransactionStatus?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetTransaction?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendExecuteScript?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetAccount?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetEvents?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetBlockHeader?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetCollection?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendPing?: (ix: Interaction, context: IContext, opts: IOptsCommon) => void + sendGetBlock?: (ix: Interaction, context: IContext, opts: IOptsCommon) => void + sendGetNetworkParameters?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + connectSubscribeEvents?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void + sendGetNodeVersionInfo?: ( + ix: Interaction, + context: IContext, + opts: IOptsCommon + ) => void +} + +export type SendFn = (ix: Interaction, context: IContext, opts: IOpts) => void diff --git a/packages/typedefs/src/sdk-transport/subscriptions.ts b/packages/typedefs/src/sdk-transport/subscriptions.ts new file mode 100644 index 000000000..53240d183 --- /dev/null +++ b/packages/typedefs/src/sdk-transport/subscriptions.ts @@ -0,0 +1,37 @@ +type SchemaItem = { + args: TArgs + data: TData +} + +// TODO: PLACEHOLDER - Replace with actual subscription topics +export enum SubscriptionTopic { + PLACEHOLDER = "PLACEHOLDER", +} + +export type SubscriptionSchema = { + [SubscriptionTopic.PLACEHOLDER]: SchemaItem< + {}, + { + placeholder: string + } + > +} + +export type SubscriptionArguments = + SubscriptionSchema[T]["args"] +export type SubscriptionData = + SubscriptionSchema[T]["data"] + +export type Subscription = { + unsubscribe: () => void +} + +export type SubscribeFn = ( + params: { + topic: T + args: SubscriptionArguments + onData: (data: SubscriptionData) => void + onError: (error: Error) => void + }, + opts: {node: string} +) => Promise From 59bf81cc9ab1345ed2cf3f295be495d5595c3a2b Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 26 Nov 2024 11:33:28 -0800 Subject: [PATCH 2/6] Add SDK subscribe function --- packages/sdk/src/account/account.js | 2 +- packages/sdk/src/block/block.js | 2 +- packages/sdk/src/contract.test.js | 2 +- .../node-version-info/node-version-info.ts | 2 +- packages/sdk/src/sdk.ts | 4 +- .../sdk/src/{send => transport}/send.test.js | 0 .../src/{send/send.js => transport/send.ts} | 26 ++++----- packages/sdk/src/transport/subscribe.test.ts | 48 ++++++++++++++++ packages/sdk/src/transport/subscribe.ts | 57 +++++++++++++++++++ packages/sdk/src/transport/transport.ts | 44 ++++++++++++++ 10 files changed, 169 insertions(+), 18 deletions(-) rename packages/sdk/src/{send => transport}/send.test.js (100%) rename packages/sdk/src/{send/send.js => transport/send.ts} (52%) create mode 100644 packages/sdk/src/transport/subscribe.test.ts create mode 100644 packages/sdk/src/transport/subscribe.ts create mode 100644 packages/sdk/src/transport/transport.ts diff --git a/packages/sdk/src/account/account.js b/packages/sdk/src/account/account.js index f3a2a75ed..54094ca86 100644 --- a/packages/sdk/src/account/account.js +++ b/packages/sdk/src/account/account.js @@ -3,7 +3,7 @@ import {atBlockId} from "../build/build-at-block-id.js" import {getAccount} from "../build/build-get-account.js" import {invariant} from "@onflow/util-invariant" import {decodeResponse as decode} from "../decode/decode.js" -import {send} from "../send/send.js" +import {send} from "../transport/send" /** * @typedef {import("@onflow/typedefs").Account} Account diff --git a/packages/sdk/src/block/block.js b/packages/sdk/src/block/block.js index 6cc92df8e..0ecdfd03e 100644 --- a/packages/sdk/src/block/block.js +++ b/packages/sdk/src/block/block.js @@ -1,4 +1,4 @@ -import {send} from "../send/send.js" +import {send} from "../transport/send" import {getBlock} from "../build/build-get-block" import {atBlockHeight} from "../build/build-at-block-height.js" import {atBlockId} from "../build/build-at-block-id.js" diff --git a/packages/sdk/src/contract.test.js b/packages/sdk/src/contract.test.js index 40f95cea8..a49d70d9f 100644 --- a/packages/sdk/src/contract.test.js +++ b/packages/sdk/src/contract.test.js @@ -2,7 +2,7 @@ import * as root from "./sdk" import * as decode from "./decode/decode.js" import * as encode from "./encode/encode" import * as interaction from "./interaction/interaction" -import * as send from "./send/send.js" +import * as send from "./transport/send" import * as template from "@onflow/util-template" const interfaceContract = diff --git a/packages/sdk/src/node-version-info/node-version-info.ts b/packages/sdk/src/node-version-info/node-version-info.ts index c905a76cd..9c73081b1 100644 --- a/packages/sdk/src/node-version-info/node-version-info.ts +++ b/packages/sdk/src/node-version-info/node-version-info.ts @@ -1,4 +1,4 @@ -import {send} from "../send/send.js" +import {send} from "../transport/send" import {decodeResponse as decode} from "../decode/decode.js" import {getNodeVersionInfo} from "../build/build-get-node-version-info" import {NodeVersionInfo} from "@onflow/typedefs" diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 9ea40de35..f3ff5fa50 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -2,7 +2,7 @@ import * as logger from "@onflow/util-logger" // Base export {build} from "./build/build.js" export {resolve} from "./resolve/resolve.js" -export {send} from "./send/send.js" +export {send} from "./transport/send" export {decode} from "./decode/sdk-decode.js" export { encodeTransactionPayload, @@ -113,3 +113,5 @@ import * as TestUtils from "./test-utils" export {TestUtils} export {VERSION} from "./VERSION" + +export {subscribe} from "./transport/subscribe" diff --git a/packages/sdk/src/send/send.test.js b/packages/sdk/src/transport/send.test.js similarity index 100% rename from packages/sdk/src/send/send.test.js rename to packages/sdk/src/transport/send.test.js diff --git a/packages/sdk/src/send/send.js b/packages/sdk/src/transport/send.ts similarity index 52% rename from packages/sdk/src/send/send.js rename to packages/sdk/src/transport/send.ts index eab60a9e4..94a0aea23 100644 --- a/packages/sdk/src/send/send.js +++ b/packages/sdk/src/transport/send.ts @@ -1,23 +1,23 @@ import {Buffer} from "@onflow/rlp" -import {send as defaultSend} from "@onflow/transport-http" import {initInteraction, pipe} from "../interaction/interaction" import * as ixModule from "../interaction/interaction" -import {invariant} from "../build/build-invariant.js" +import {invariant} from "../build/build-invariant" import {response} from "../response/response" import {config} from "@onflow/config" -import {resolve as defaultResolve} from "../resolve/resolve.js" +import {resolve as defaultResolve} from "../resolve/resolve" +import {getTransport} from "./transport" /** * @description - Sends arbitrary scripts, transactions, and requests to Flow - * @param {Array. | Function} args - An array of functions that take interaction and return interaction - * @param {object} opts - Optional parameters - * @returns {Promise<*>} - A promise that resolves to a response + * @param args - An array of functions that take interaction and return interaction + * @param opts - Optional parameters + * @returns - A promise that resolves to a response */ -export const send = async (args = [], opts = {}) => { - const sendFn = await config.first( - ["sdk.transport", "sdk.send"], - opts.send || defaultSend - ) +export const send = async ( + args: Function | Function[] = [], + opts: any = {} +): Promise => { + const {send: sendFn} = await getTransport(opts) invariant( sendFn, @@ -31,10 +31,10 @@ export const send = async (args = [], opts = {}) => { opts.node = opts.node || (await config().get("accessNode.api")) - if (Array.isArray(args)) args = pipe(initInteraction(), args) + if (Array.isArray(args)) args = pipe(initInteraction(), args as any) as any return sendFn( await resolveFn(args), - {config, response, ix: ixModule, Buffer}, + {config, response, ix: ixModule, Buffer} as any, opts ) } diff --git a/packages/sdk/src/transport/subscribe.test.ts b/packages/sdk/src/transport/subscribe.test.ts new file mode 100644 index 000000000..3cf7f515a --- /dev/null +++ b/packages/sdk/src/transport/subscribe.test.ts @@ -0,0 +1,48 @@ +import {config} from "@onflow/config" +import {subscribe} from "./subscribe" +import {SdkTransport} from "@onflow/typedefs" +import {getTransport} from "./transport" + +jest.mock("./transport") + +describe("subscribe", () => { + let mockTransport: jest.Mocked + + beforeEach(() => { + jest.resetAllMocks() + + mockTransport = { + subscribe: jest.fn().mockReturnValue({ + unsubscribe: jest.fn(), + }), + send: jest.fn(), + } + jest.mocked(getTransport).mockResolvedValue(mockTransport) + }) + + test("subscribes to a topic and returns a subscription", async () => { + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {foo: "bar"} as SdkTransport.SubscriptionArguments + const onData = jest.fn() + const onError = jest.fn() + + const sub = await config().overload( + { + "accessNode.api": "http://localhost:8080", + }, + async () => { + return await subscribe({topic, args, onData, onError}) + } + ) + + expect(mockTransport.subscribe).toHaveBeenCalledTimes(1) + expect(mockTransport.subscribe).toHaveBeenCalledWith( + {topic, args, onData: expect.any(Function), onError}, + {node: "http://localhost:8080"} + ) + + expect(sub).toStrictEqual({ + unsubscribe: expect.any(Function), + }) + }) +}) diff --git a/packages/sdk/src/transport/subscribe.ts b/packages/sdk/src/transport/subscribe.ts new file mode 100644 index 000000000..ea74756b6 --- /dev/null +++ b/packages/sdk/src/transport/subscribe.ts @@ -0,0 +1,57 @@ +import {config} from "@onflow/config" +import {SdkTransport} from "@onflow/typedefs" +import {getTransport} from "./transport" +import {invariant} from "@onflow/util-invariant" + +// TODO: OPTS FUNCTION +export async function subscribe( + { + topic, + args, + onData, + onError, + }: { + topic: T + args: SdkTransport.SubscriptionArguments + onData: (data: SdkTransport.SubscriptionData) => void + onError: (error: Error) => void + }, + opts: { + node?: string + send?: SdkTransport.SendFn + transport?: SdkTransport.Transport + } = {} +) { + const transport = await getTransport(opts) + const node = opts?.node || (await config().get("accessNode.api")) + + invariant( + !!node, + `SDK Send Error: Either opts.node or "accessNode.api" in config must be defined.` + ) + + // TODO: handle onError + // Subscribe using the resolved transport + return transport.subscribe( + { + topic, + args, + onData: data => { + // TODO: decode function + onData(decode(topic, data)) + }, + onError, + }, + { + node, + ...opts, + } + ) +} + +export function decode( + topic: T, + data: SdkTransport.SubscriptionData +): any { + return data +} diff --git a/packages/sdk/src/transport/transport.ts b/packages/sdk/src/transport/transport.ts new file mode 100644 index 000000000..aa23ba5d6 --- /dev/null +++ b/packages/sdk/src/transport/transport.ts @@ -0,0 +1,44 @@ +import {config} from "@onflow/config" +import {httpTransport as defaultTransport} from "@onflow/transport-http" +import {SdkTransport} from "@onflow/typedefs" +import {invariant} from "@onflow/util-invariant" + +export async function getTransport( + opts: { + send?: SdkTransport.SendFn + transport?: SdkTransport.Transport + } = {} +): Promise { + invariant( + opts.send == null || opts.transport == null, + `SDK Transport Error: Cannot provide both "transport" and legacy "send" options.` + ) + + const transportOrSend = await config().first< + SdkTransport.Transport | SdkTransport.SendFn + >( + ["sdk.transport", "sdk.send"], + opts.transport || opts.send || defaultTransport + ) + + if (isTransportObject(transportOrSend)) { + // This is a transport object, return it directly + return transportOrSend + } else { + // This is a legacy send function, wrap it in a transport object + return { + send: transportOrSend, + subscribe: () => { + throw new Error( + "Subscribe not supported with legacy send function transport, please provide a transport object." + ) + }, + } + } +} + +function isTransportObject( + transport: any +): transport is SdkTransport.Transport { + return transport.send !== undefined && transport.subscribe !== undefined +} From 547a6497b5976d25bd8a08e2c271b6ff67c0f318 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 26 Nov 2024 19:06:48 -0800 Subject: [PATCH 3/6] refactor --- packages/sdk/src/account/account.js | 2 +- packages/sdk/src/block/block.js | 2 +- packages/sdk/src/contract.test.js | 2 +- .../node-version-info/node-version-info.ts | 2 +- packages/sdk/src/sdk.ts | 4 +- .../{transport.ts => get-transport.ts} | 19 +-- packages/sdk/src/transport/index.ts | 3 + .../sdk/src/transport/{ => send}/send.test.js | 0 packages/sdk/src/transport/{ => send}/send.ts | 15 +-- .../raw-subscribe.test.ts} | 23 ++-- .../raw-subscribe.ts} | 10 +- .../src/transport/subscribe/subscribe.test.ts | 112 ++++++++++++++++++ .../sdk/src/transport/subscribe/subscribe.ts | 40 +++++++ 13 files changed, 194 insertions(+), 40 deletions(-) rename packages/sdk/src/transport/{transport.ts => get-transport.ts} (72%) create mode 100644 packages/sdk/src/transport/index.ts rename packages/sdk/src/transport/{ => send}/send.test.js (100%) rename packages/sdk/src/transport/{ => send}/send.ts (69%) rename packages/sdk/src/transport/{subscribe.test.ts => subscribe/raw-subscribe.test.ts} (63%) rename packages/sdk/src/transport/{subscribe.ts => subscribe/raw-subscribe.ts} (80%) create mode 100644 packages/sdk/src/transport/subscribe/subscribe.test.ts create mode 100644 packages/sdk/src/transport/subscribe/subscribe.ts diff --git a/packages/sdk/src/account/account.js b/packages/sdk/src/account/account.js index 54094ca86..59709f96a 100644 --- a/packages/sdk/src/account/account.js +++ b/packages/sdk/src/account/account.js @@ -3,7 +3,7 @@ import {atBlockId} from "../build/build-at-block-id.js" import {getAccount} from "../build/build-get-account.js" import {invariant} from "@onflow/util-invariant" import {decodeResponse as decode} from "../decode/decode.js" -import {send} from "../transport/send" +import {send} from "../transport" /** * @typedef {import("@onflow/typedefs").Account} Account diff --git a/packages/sdk/src/block/block.js b/packages/sdk/src/block/block.js index 0ecdfd03e..b039a9334 100644 --- a/packages/sdk/src/block/block.js +++ b/packages/sdk/src/block/block.js @@ -1,4 +1,4 @@ -import {send} from "../transport/send" +import {send} from "../transport/send/send" import {getBlock} from "../build/build-get-block" import {atBlockHeight} from "../build/build-at-block-height.js" import {atBlockId} from "../build/build-at-block-id.js" diff --git a/packages/sdk/src/contract.test.js b/packages/sdk/src/contract.test.js index a49d70d9f..374257ef5 100644 --- a/packages/sdk/src/contract.test.js +++ b/packages/sdk/src/contract.test.js @@ -2,7 +2,7 @@ import * as root from "./sdk" import * as decode from "./decode/decode.js" import * as encode from "./encode/encode" import * as interaction from "./interaction/interaction" -import * as send from "./transport/send" +import * as send from "./transport" import * as template from "@onflow/util-template" const interfaceContract = diff --git a/packages/sdk/src/node-version-info/node-version-info.ts b/packages/sdk/src/node-version-info/node-version-info.ts index 9c73081b1..76d38e25f 100644 --- a/packages/sdk/src/node-version-info/node-version-info.ts +++ b/packages/sdk/src/node-version-info/node-version-info.ts @@ -1,4 +1,4 @@ -import {send} from "../transport/send" +import {send} from "../transport/send/send" import {decodeResponse as decode} from "../decode/decode.js" import {getNodeVersionInfo} from "../build/build-get-node-version-info" import {NodeVersionInfo} from "@onflow/typedefs" diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index f3ff5fa50..0a8bc9eeb 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -2,7 +2,7 @@ import * as logger from "@onflow/util-logger" // Base export {build} from "./build/build.js" export {resolve} from "./resolve/resolve.js" -export {send} from "./transport/send" +export {send} from "./transport" export {decode} from "./decode/sdk-decode.js" export { encodeTransactionPayload, @@ -114,4 +114,4 @@ export {TestUtils} export {VERSION} from "./VERSION" -export {subscribe} from "./transport/subscribe" +export {subscribe} from "./transport" diff --git a/packages/sdk/src/transport/transport.ts b/packages/sdk/src/transport/get-transport.ts similarity index 72% rename from packages/sdk/src/transport/transport.ts rename to packages/sdk/src/transport/get-transport.ts index aa23ba5d6..90557b1a7 100644 --- a/packages/sdk/src/transport/transport.ts +++ b/packages/sdk/src/transport/get-transport.ts @@ -3,14 +3,17 @@ import {httpTransport as defaultTransport} from "@onflow/transport-http" import {SdkTransport} from "@onflow/typedefs" import {invariant} from "@onflow/util-invariant" -export async function getTransport( - opts: { - send?: SdkTransport.SendFn - transport?: SdkTransport.Transport - } = {} -): Promise { +/** + * Get the SDK transport object, either from the provided override or from the global config. + * @param overrides + * @returns + */ +export async function getTransport(override: { + send?: SdkTransport.SendFn + transport?: SdkTransport.Transport +}): Promise { invariant( - opts.send == null || opts.transport == null, + override.send == null || override.transport == null, `SDK Transport Error: Cannot provide both "transport" and legacy "send" options.` ) @@ -18,7 +21,7 @@ export async function getTransport( SdkTransport.Transport | SdkTransport.SendFn >( ["sdk.transport", "sdk.send"], - opts.transport || opts.send || defaultTransport + override.transport || override.send || defaultTransport ) if (isTransportObject(transportOrSend)) { diff --git a/packages/sdk/src/transport/index.ts b/packages/sdk/src/transport/index.ts new file mode 100644 index 000000000..cf3d3ae64 --- /dev/null +++ b/packages/sdk/src/transport/index.ts @@ -0,0 +1,3 @@ +export {send} from "./send/send" +export {subscribe} from "./subscribe/subscribe" +export {rawSubscribe} from "./subscribe/raw-subscribe" diff --git a/packages/sdk/src/transport/send.test.js b/packages/sdk/src/transport/send/send.test.js similarity index 100% rename from packages/sdk/src/transport/send.test.js rename to packages/sdk/src/transport/send/send.test.js diff --git a/packages/sdk/src/transport/send.ts b/packages/sdk/src/transport/send/send.ts similarity index 69% rename from packages/sdk/src/transport/send.ts rename to packages/sdk/src/transport/send/send.ts index 94a0aea23..02ce8f1a6 100644 --- a/packages/sdk/src/transport/send.ts +++ b/packages/sdk/src/transport/send/send.ts @@ -1,11 +1,11 @@ import {Buffer} from "@onflow/rlp" -import {initInteraction, pipe} from "../interaction/interaction" -import * as ixModule from "../interaction/interaction" -import {invariant} from "../build/build-invariant" -import {response} from "../response/response" +import {initInteraction, pipe} from "../../interaction/interaction" +import * as ixModule from "../../interaction/interaction" +import {invariant} from "../../build/build-invariant" +import {response} from "../../response/response" import {config} from "@onflow/config" -import {resolve as defaultResolve} from "../resolve/resolve" -import {getTransport} from "./transport" +import {resolve as defaultResolve} from "../../resolve/resolve" +import {getTransport} from "../get-transport" /** * @description - Sends arbitrary scripts, transactions, and requests to Flow @@ -17,7 +17,8 @@ export const send = async ( args: Function | Function[] = [], opts: any = {} ): Promise => { - const {send: sendFn} = await getTransport(opts) + const transport = await getTransport(opts) + const sendFn = transport.send.bind(transport) invariant( sendFn, diff --git a/packages/sdk/src/transport/subscribe.test.ts b/packages/sdk/src/transport/subscribe/raw-subscribe.test.ts similarity index 63% rename from packages/sdk/src/transport/subscribe.test.ts rename to packages/sdk/src/transport/subscribe/raw-subscribe.test.ts index 3cf7f515a..bbf134730 100644 --- a/packages/sdk/src/transport/subscribe.test.ts +++ b/packages/sdk/src/transport/subscribe/raw-subscribe.test.ts @@ -1,26 +1,27 @@ import {config} from "@onflow/config" -import {subscribe} from "./subscribe" +import {rawSubscribe} from "./raw-subscribe" import {SdkTransport} from "@onflow/typedefs" -import {getTransport} from "./transport" +import {getTransport} from "../get-transport" -jest.mock("./transport") +jest.mock("../get-transport") describe("subscribe", () => { let mockTransport: jest.Mocked + let mockSub: jest.Mocked = { + unsubscribe: jest.fn(), + } beforeEach(() => { jest.resetAllMocks() mockTransport = { - subscribe: jest.fn().mockReturnValue({ - unsubscribe: jest.fn(), - }), + subscribe: jest.fn().mockReturnValue(mockSub), send: jest.fn(), } jest.mocked(getTransport).mockResolvedValue(mockTransport) }) - test("subscribes to a topic and returns a subscription", async () => { + test("subscribes to a topic and returns subscription from transport", async () => { const topic = "topic" as SdkTransport.SubscriptionTopic const args = {foo: "bar"} as SdkTransport.SubscriptionArguments const onData = jest.fn() @@ -31,18 +32,16 @@ describe("subscribe", () => { "accessNode.api": "http://localhost:8080", }, async () => { - return await subscribe({topic, args, onData, onError}) + return await rawSubscribe({topic, args, onData, onError}) } ) expect(mockTransport.subscribe).toHaveBeenCalledTimes(1) expect(mockTransport.subscribe).toHaveBeenCalledWith( - {topic, args, onData: expect.any(Function), onError}, + {topic, args, onData: onData, onError}, {node: "http://localhost:8080"} ) - expect(sub).toStrictEqual({ - unsubscribe: expect.any(Function), - }) + expect(sub).toBe(mockSub) }) }) diff --git a/packages/sdk/src/transport/subscribe.ts b/packages/sdk/src/transport/subscribe/raw-subscribe.ts similarity index 80% rename from packages/sdk/src/transport/subscribe.ts rename to packages/sdk/src/transport/subscribe/raw-subscribe.ts index ea74756b6..3bd1d5633 100644 --- a/packages/sdk/src/transport/subscribe.ts +++ b/packages/sdk/src/transport/subscribe/raw-subscribe.ts @@ -1,10 +1,10 @@ import {config} from "@onflow/config" import {SdkTransport} from "@onflow/typedefs" -import {getTransport} from "./transport" +import {getTransport} from "../get-transport" import {invariant} from "@onflow/util-invariant" // TODO: OPTS FUNCTION -export async function subscribe( +export async function rawSubscribe( { topic, args, @@ -18,7 +18,6 @@ export async function subscribe( }, opts: { node?: string - send?: SdkTransport.SendFn transport?: SdkTransport.Transport } = {} ) { @@ -36,10 +35,7 @@ export async function subscribe( { topic, args, - onData: data => { - // TODO: decode function - onData(decode(topic, data)) - }, + onData, onError, }, { diff --git a/packages/sdk/src/transport/subscribe/subscribe.test.ts b/packages/sdk/src/transport/subscribe/subscribe.test.ts new file mode 100644 index 000000000..ad7be7950 --- /dev/null +++ b/packages/sdk/src/transport/subscribe/subscribe.test.ts @@ -0,0 +1,112 @@ +import {SdkTransport} from "@onflow/typedefs" +import {subscribe} from "./subscribe" +import {rawSubscribe} from "./raw-subscribe" + +jest.mock("./raw-subscribe") +const mockRawSubscribe = jest.mocked(rawSubscribe) + +describe("subscribe", () => { + let mockSub: jest.Mocked = { + unsubscribe: jest.fn(), + } + + beforeEach(() => { + jest.resetAllMocks() + mockRawSubscribe.mockResolvedValue(mockSub) + }) + + test("subscribes to a topic and returns a subscription", async () => { + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {foo: "bar"} as SdkTransport.SubscriptionArguments + const onData = jest.fn() + const onError = jest.fn() + + const sub = await subscribe({ + topic, + args, + onData, + onError, + }) + + expect(sub).toBe(mockSub) + expect(mockRawSubscribe).toHaveBeenCalledTimes(1) + expect(mockRawSubscribe).toHaveBeenCalledWith( + {topic, args, onData: expect.any(Function), onError}, + {} + ) + }) + + test("unsubscribes from a subscription", async () => { + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {foo: "bar"} as SdkTransport.SubscriptionArguments + const onData = jest.fn() + const onError = jest.fn() + + const sub = await subscribe({ + topic, + args, + onData, + onError, + }) + + sub.unsubscribe() + + expect(mockSub.unsubscribe).toHaveBeenCalledTimes(1) + }) + + test("subscribes to a topic with a node", async () => { + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {foo: "bar"} as SdkTransport.SubscriptionArguments + const onData = jest.fn() + const onError = jest.fn() + + const node = "http://localhost:8080" + + const sub = await subscribe( + { + topic, + args, + onData, + onError, + }, + {node} + ) + + expect(sub).toBe(mockSub) + expect(mockRawSubscribe).toHaveBeenCalledTimes(1) + expect(mockRawSubscribe).toHaveBeenCalledWith( + {topic, args, onData: expect.any(Function), onError}, + {node} + ) + }) + + test("subscribes to a topic with custom node and transport", async () => { + const topic = "topic" as SdkTransport.SubscriptionTopic + const args = {foo: "bar"} as SdkTransport.SubscriptionArguments + const onData = jest.fn() + const onError = jest.fn() + + const node = "http://localhost:8080" + const transport = { + send: jest.fn(), + subscribe: jest.fn().mockResolvedValue(mockSub), + } as jest.Mocked + + const sub = await subscribe( + { + topic, + args, + onData, + onError, + }, + {node, transport} + ) + + expect(sub).toBe(mockSub) + expect(mockRawSubscribe).toHaveBeenCalledTimes(1) + expect(mockRawSubscribe).toHaveBeenCalledWith( + {topic, args, onData: expect.any(Function), onError}, + {node, transport} + ) + }) +}) diff --git a/packages/sdk/src/transport/subscribe/subscribe.ts b/packages/sdk/src/transport/subscribe/subscribe.ts new file mode 100644 index 000000000..ff8a0b4b5 --- /dev/null +++ b/packages/sdk/src/transport/subscribe/subscribe.ts @@ -0,0 +1,40 @@ +import {SdkTransport} from "@onflow/typedefs" +import {rawSubscribe} from "./raw-subscribe" +import {decodeResponse} from "../../decode/decode" + +export async function subscribe( + { + topic, + args, + onData, + onError, + }: { + topic: T + args: SdkTransport.SubscriptionArguments + onData: (data: SdkTransport.SubscriptionData) => void + onError: (error: Error) => void + }, + opts: { + node?: string + transport?: SdkTransport.Transport + } = {} +): Promise { + const sub = await rawSubscribe( + { + topic, + args, + onData: data => { + decodeResponse(data) + .then(onData) + .catch(e => { + onError(new Error(`Failed to subscription data: ${e}`)) + sub.unsubscribe() + }) + }, + onError, + }, + opts + ) + + return sub +} From 6aa731876bffc54c49ba4d704fdb2adec510d3b3 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 26 Nov 2024 20:11:36 -0800 Subject: [PATCH 4/6] cleanup --- packages/sdk/src/transport/get-transport.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/transport/get-transport.ts b/packages/sdk/src/transport/get-transport.ts index 90557b1a7..1ef253dee 100644 --- a/packages/sdk/src/transport/get-transport.ts +++ b/packages/sdk/src/transport/get-transport.ts @@ -5,8 +5,8 @@ import {invariant} from "@onflow/util-invariant" /** * Get the SDK transport object, either from the provided override or from the global config. - * @param overrides - * @returns + * @param overrides - Override default configuration with custom transport or send function. + * @returns The SDK transport object. */ export async function getTransport(override: { send?: SdkTransport.SendFn @@ -24,11 +24,8 @@ export async function getTransport(override: { override.transport || override.send || defaultTransport ) - if (isTransportObject(transportOrSend)) { - // This is a transport object, return it directly - return transportOrSend - } else { - // This is a legacy send function, wrap it in a transport object + // Backwards compatibility with legacy send function + if (!isTransportObject(transportOrSend)) { return { send: transportOrSend, subscribe: () => { @@ -38,6 +35,8 @@ export async function getTransport(override: { }, } } + + return transportOrSend } function isTransportObject( From a2caec8dacca6d894a8bcf087a3a3ea80047e7b9 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Tue, 26 Nov 2024 21:23:07 -0800 Subject: [PATCH 5/6] Add tests --- .../sdk/src/transport/get-transport.test.ts | 135 ++++++++++++++++++ packages/sdk/src/transport/get-transport.ts | 10 +- 2 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/src/transport/get-transport.test.ts diff --git a/packages/sdk/src/transport/get-transport.test.ts b/packages/sdk/src/transport/get-transport.test.ts new file mode 100644 index 000000000..4470093b2 --- /dev/null +++ b/packages/sdk/src/transport/get-transport.test.ts @@ -0,0 +1,135 @@ +import {SdkTransport} from "@onflow/typedefs" +import {getTransport} from "./get-transport" +import {httpTransport} from "@onflow/transport-http" +import {config} from "@onflow/config" + +jest.mock("@onflow/transport-http", () => ({ + httpTransport: { + send: jest.fn(), + subscribe: jest.fn(), + } as jest.Mocked, +})) + +describe("getTransport", () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + test("fallback to http transport", async () => { + const transport = await getTransport() + expect(transport).toBe(httpTransport) + }) + + test("override with custom transport", async () => { + const customTransport = { + send: jest.fn(), + subscribe: jest.fn(), + } as jest.Mocked + + const transport = await getTransport({transport: customTransport}) + expect(transport).toBe(customTransport) + }) + + test("override with custom send function", async () => { + const customSend = jest.fn() + + const transport = await getTransport({send: customSend}) + expect(transport).toEqual({ + send: customSend, + subscribe: expect.any(Function), + }) + }) + + test("override with both custom transport and send function", async () => { + await expect( + getTransport({ + send: jest.fn(), + transport: { + send: jest.fn(), + subscribe: jest.fn(), + }, + }) + ).rejects.toThrow( + /Cannot provide both "transport" and legacy "send" options/ + ) + }) + + test("transport from global config - sdk.transport", async () => { + const customTransport = { + send: jest.fn(), + subscribe: jest.fn(), + } as jest.Mocked + + const tranpsort = await config().overload( + { + "sdk.transport": customTransport, + }, + async () => { + return await getTransport() + } + ) + + expect(tranpsort).toBe(customTransport) + }) + + test("send function from global config - sdk.transport", async () => { + const customSend = jest.fn() + + const transport = await config().overload( + { + "sdk.transport": customSend, + }, + async () => { + return await getTransport() + } + ) + expect(transport).toEqual({ + send: customSend, + subscribe: expect.any(Function), + }) + }) + + test("send function from global config - sdk.send", async () => { + const customSend = jest.fn() + + const transport = await config().overload( + { + "sdk.send": customSend, + }, + async () => { + return await getTransport() + } + ) + + expect(transport).toEqual({ + send: customSend, + subscribe: expect.any(Function), + }) + }) + + /** + * TODO: (jribbink) Figure out what to do with this logic. + * + * Currently, and previously, this logic is the reverse where the global config has priority over the custom transport. + * I disagree with this logic and believe that the custom transport should have priority over the global config. + * However, it would be a breaking change to change this logic. + * + */ + /*test("custom transport has priority over global config", async () => { + const customTransport = { + send: jest.fn(), + subscribe: jest.fn(), + } as jest.Mocked + + const transport = await config().overload( + { + "sdk.transport": httpTransport, + }, + async () => { + return await getTransport({transport: customTransport}) + } + ) + + expect(transport).toBe(customTransport) + })*/ +}) diff --git a/packages/sdk/src/transport/get-transport.ts b/packages/sdk/src/transport/get-transport.ts index 1ef253dee..fd1de2f98 100644 --- a/packages/sdk/src/transport/get-transport.ts +++ b/packages/sdk/src/transport/get-transport.ts @@ -8,10 +8,12 @@ import {invariant} from "@onflow/util-invariant" * @param overrides - Override default configuration with custom transport or send function. * @returns The SDK transport object. */ -export async function getTransport(override: { - send?: SdkTransport.SendFn - transport?: SdkTransport.Transport -}): Promise { +export async function getTransport( + override: { + send?: SdkTransport.SendFn + transport?: SdkTransport.Transport + } = {} +): Promise { invariant( override.send == null || override.transport == null, `SDK Transport Error: Cannot provide both "transport" and legacy "send" options.` From f7f7c4f34fe466b56859d521856dc38d0cdc0d1a Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Fri, 29 Nov 2024 09:37:57 -0800 Subject: [PATCH 6/6] PKG -- [fcl] Expose SDK subscriptions --- packages/fcl-core/src/fcl-core.ts | 4 ++++ packages/fcl/src/fcl.ts | 3 +++ packages/sdk/src/sdk.ts | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/fcl-core/src/fcl-core.ts b/packages/fcl-core/src/fcl-core.ts index a031b2ca5..72381c907 100644 --- a/packages/fcl-core/src/fcl-core.ts +++ b/packages/fcl-core/src/fcl-core.ts @@ -69,6 +69,10 @@ export {params, param} from "@onflow/sdk" export {validator} from "@onflow/sdk" export {invariant} from "@onflow/sdk" +// subscriptions +export {subscribe} from "@onflow/sdk" +export {rawSubscribe} from "@onflow/sdk" + /** * @typedef {object} Types * @property {any} Identity - Represents the Identity type. diff --git a/packages/fcl/src/fcl.ts b/packages/fcl/src/fcl.ts index 6f6b5e97a..05ba79d1d 100644 --- a/packages/fcl/src/fcl.ts +++ b/packages/fcl/src/fcl.ts @@ -110,3 +110,6 @@ initServiceRegistry({coreStrategies}) initFclWcLoader() export {LOCAL_STORAGE, SESSION_STORAGE} from "./utils/web" + +// subscriptions +export {subscribe, rawSubscribe} from "@onflow/fcl-core" diff --git a/packages/sdk/src/sdk.ts b/packages/sdk/src/sdk.ts index 0a8bc9eeb..de82220d1 100644 --- a/packages/sdk/src/sdk.ts +++ b/packages/sdk/src/sdk.ts @@ -114,4 +114,4 @@ export {TestUtils} export {VERSION} from "./VERSION" -export {subscribe} from "./transport" +export {subscribe, rawSubscribe} from "./transport"