From 6e9adf73d317b82df590465fab6bc4fd7eb4e802 Mon Sep 17 00:00:00 2001 From: Ronnie Dutta <61982285+MetRonnie@users.noreply.github.com> Date: Tue, 18 Nov 2025 18:23:13 +0000 Subject: [PATCH] Migrate from `subscriptions-transport-ws` to `graphql-ws` The `subscriptions-transport-ws` library is no longer maintained. --- package.json | 2 +- src/graphql/index.js | 70 +++++++++++++------------------- src/services/mock/websockets.cjs | 63 +++++++++++++--------------- src/services/workflow.service.js | 2 +- yarn.lock | 66 ++++++++++-------------------- 5 files changed, 79 insertions(+), 124 deletions(-) diff --git a/package.json b/package.json index 5f53d067a..0cbbaab41 100644 --- a/package.json +++ b/package.json @@ -37,13 +37,13 @@ "graphiql": "4.1.2", "graphql": "16.13.2", "graphql-tag": "2.12.6", + "graphql-ws": "6.0.4", "lodash-es": "4.18.1", "markdown-it": "14.1.1", "mitt": "3.0.1", "nprogress": "1.0.0-1", "preact": "10.27.3", "simple-icons": "16.10.0", - "subscriptions-transport-ws": "0.11.0", "svg-pan-zoom": "3.6.2", "vue": "3.5.33", "vue-i18n": "11.1.12", diff --git a/src/graphql/index.js b/src/graphql/index.js index ab3591d0e..fc8ab2523 100644 --- a/src/graphql/index.js +++ b/src/graphql/index.js @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -import { SubscriptionClient } from 'subscriptions-transport-ws' +import { createClient } from 'graphql-ws' import { ApolloClient, ApolloLink, @@ -23,13 +23,12 @@ import { InMemoryCache, split } from '@apollo/client/core' +import { GraphQLWsLink } from '@apollo/client/link/subscriptions' import { getMainDefinition } from '@apollo/client/utilities' -import { WebSocketLink } from '@apollo/client/link/ws' import { setContext } from '@apollo/client/link/context' import { store } from '@/store/index' import { createUrl, getXSRFHeaders } from '@/utils/urls' - -/** @typedef {import('subscriptions-transport-ws').ClientOptions} ClientOptions */ +import { uniqueId } from 'lodash-es' /** * Create the HTTP and WebSocket URLs for an ApolloClient. @@ -50,45 +49,32 @@ export function createGraphQLUrls () { * Create a subscription client. * * @private - * @param {string} wsUrl - WebSocket subscription URL - * @param {ClientOptions} options - SubscriptionClient options - * @param {*} wsImpl - WebSocket implementation (native by default) - * @return {SubscriptionClient} a subscription client + * @param {string} url - WebSocket subscription URL + * @return {import('graphql-ws').Client} a subscription client */ -export function createSubscriptionClient (wsUrl, options = {}, wsImpl = null) { - /** @type {ClientOptions} */ - const opts = { - reconnect: true, +export function createSubscriptionClient (url) { + return createClient({ + url, lazy: false, - // Raise initial connection timeout from 1->3 secs to try to mitigate slow connection problem - // https://github.com/cylc/cylc-ui/issues/1200 - minTimeout: 3e3, - ...options, - } - const subscriptionClient = new SubscriptionClient(wsUrl, opts, wsImpl) - // these are the available hooks in the subscription client lifecycle - subscriptionClient.onConnecting(() => { - store.commit('SET_OFFLINE', true) - }) - subscriptionClient.onConnected(() => { - store.commit('SET_OFFLINE', false) - }) - subscriptionClient.onReconnecting(() => { - store.commit('SET_OFFLINE', true) - }) - subscriptionClient.onReconnected(() => { - store.commit('SET_OFFLINE', false) - }) - subscriptionClient.onDisconnected(() => { - store.commit('SET_OFFLINE', true) + on: { + connecting (isRetry) { + if (isRetry) console.warn('Retrying WS connection') + store.commit('SET_OFFLINE', true) + }, + connected (socket, payload, wasRetry) { + store.commit('SET_OFFLINE', false) + }, + closed (event) { + store.commit('SET_OFFLINE', true) + }, + error (error) { + console.error(error) + store.commit('SET_OFFLINE', true) + // TODO: store.commit('SET_ALERT') with the error details? + }, + }, + generateID: (payload) => uniqueId(payload.operationName) }) - // TODO: at the moment the error displays an Event object, but the browser also displays the problem, as well as the offline indicator - // would be nice to find a better error message using the error object - // subscriptionClient.onError((error) => { - // console.error(error) - // store.commit('SET_ALERT', new Alert(error, 'error')) - // }) - return subscriptionClient } /** @@ -107,7 +93,7 @@ export function createSubscriptionClient (wsUrl, options = {}, wsImpl = null) { * * @public * @param {string} httpUrl - * @param {SubscriptionClient|null} subscriptionClient + * @param {?import('graphql-ws').Client} subscriptionClient * @returns {ApolloClient} an ApolloClient */ export function createApolloClient (httpUrl, subscriptionClient) { @@ -116,7 +102,7 @@ export function createApolloClient (httpUrl, subscriptionClient) { }) const wsLink = subscriptionClient !== null - ? new WebSocketLink(subscriptionClient) + ? new GraphQLWsLink(subscriptionClient) : new ApolloLink() // return an empty link, useful for testing, offline mode, etc const link = split( diff --git a/src/services/mock/websockets.cjs b/src/services/mock/websockets.cjs index 1334d9b0d..c84f4460f 100644 --- a/src/services/mock/websockets.cjs +++ b/src/services/mock/websockets.cjs @@ -15,55 +15,48 @@ * along with this program. If not, see . */ +const { MessageType } = require('graphql-ws') const { isArray } = require('lodash') const graphql = require('./graphql.cjs') -/** - * Create a WebSockets response. - * - * @param {string} id - Subscription ID - * @param {string} type - Message type (e.g. data, connection_init, etc, see GraphQL spec) - * @param {Object} [data] - Response data, optional - * @returns {string} - */ -function wsResponse (id, type, data = null) { - const response = { - id, - type - } - // connection ack does not include a payload - if (data) { - response.payload = { - data - } - } - return JSON.stringify(response) -} - /** * Send a WebSockets reply message(s), given the query message (received from client). * + * @see https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md + * * @param {WebSocket} ws * @param {string} msg - JSON encoded client message */ async function sendWSResponse (ws, msg) { + /** @type {import('graphql-ws').Message} */ const parsed = JSON.parse(msg) if (parsed) { - if (parsed.type === 'connection_init') { - return ws.send(wsResponse(parsed.id, 'connection_ack')) - } else if (parsed.type === 'stop') { - return ws.send(wsResponse(parsed.id, 'complete')) - } else if (parsed.type === 'start') { - const operationName = ( - parsed.payload.operationName || graphql.getOperationName(parsed.payload.query) - ) - const responseData = await graphql.getGraphQLQueryResponse(operationName, parsed.payload.variables) - for (const item of isArray(responseData) ? responseData : [responseData]) { - ws.send(wsResponse(parsed.id, 'data', item)) + switch (parsed.type) { + case MessageType.ConnectionInit: + return ws.send(JSON.stringify({ type: MessageType.ConnectionAck })) + case MessageType.Ping: + return ws.send(JSON.stringify({ type: MessageType.Pong })) + case MessageType.Pong: + case MessageType.Complete: + return + case MessageType.Subscribe: { + const operationName = ( + parsed.payload.operationName || graphql.getOperationName(parsed.payload.query) + ) + const responseData = await graphql.getGraphQLQueryResponse(operationName, parsed.payload.variables) + for (const item of isArray(responseData) ? responseData : [responseData]) { + ws.send(JSON.stringify({ + id: parsed.id, + type: MessageType.Next, + payload: { data: item }, + })) + } + return + } + default: { + throw new Error(`Invalid message type for graphql-transport-ws: ${parsed.type}`) } - return } - throw new Error(`Unknown message type ${parsed.type}`) } throw new Error(`Failed to parse msg: ${msg}`) } diff --git a/src/services/workflow.service.js b/src/services/workflow.service.js index b8cfcf4d1..88f3cf7d8 100644 --- a/src/services/workflow.service.js +++ b/src/services/workflow.service.js @@ -41,7 +41,7 @@ import CylcTreeCallback from '@/services/treeCallback' /** @typedef {import('graphql').DocumentNode} DocumentNode */ /** @typedef {import('graphql').IntrospectionInputType} IntrospectionInputType */ -/** @typedef {import('subscriptions-transport-ws').SubscriptionClient} SubscriptionClient */ +/** @typedef {import('graphql-ws').Client} SubscriptionClient */ /** @typedef {import('@/utils/aotf').Mutation} Mutation */ /** @typedef {import('@/utils/aotf').MutationResponse} MutationResponse */ /** @typedef {import('@/utils/aotf').Query} Query */ diff --git a/yarn.lock b/yarn.lock index 5f0e526c3..6c953f2ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3348,13 +3348,6 @@ __metadata: languageName: node linkType: hard -"backo2@npm:^1.0.2": - version: 1.0.2 - resolution: "backo2@npm:1.0.2" - checksum: 10c0/a9e825a6a38a6d1c4a94476eabc13d6127dfaafb0967baf104affbb67806ae26abbb58dab8d572d2cd21ef06634ff57c3ad48dff14b904e18de1474cc2f22bf3 - languageName: node - linkType: hard - "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -4184,6 +4177,7 @@ __metadata: graphiql: "npm:4.1.2" graphql: "npm:16.13.2" graphql-tag: "npm:2.12.6" + graphql-ws: "npm:6.0.4" istanbul-lib-coverage: "npm:3.2.2" jsdom: "npm:29.0.2" json-server: "npm:0.17.4" @@ -4198,7 +4192,6 @@ __metadata: simple-icons: "npm:16.10.0" sinon: "npm:21.1.2" standard: "npm:17.1.2" - subscriptions-transport-ws: "npm:0.11.0" svg-pan-zoom: "npm:3.6.2" vite: "npm:7.3.2" vite-plugin-eslint: "npm:1.8.1" @@ -5444,13 +5437,6 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^3.1.0": - version: 3.1.2 - resolution: "eventemitter3@npm:3.1.2" - checksum: 10c0/c67262eccbf85848b7cc6d4abb6c6e34155e15686db2a01c57669fd0d44441a574a19d44d25948b442929e065774cbe5003d8e77eed47674fbf876ac77887793 - languageName: node - linkType: hard - "execa@npm:4.1.0": version: 4.1.0 resolution: "execa@npm:4.1.0" @@ -6248,6 +6234,25 @@ __metadata: languageName: node linkType: hard +"graphql-ws@npm:6.0.4": + version: 6.0.4 + resolution: "graphql-ws@npm:6.0.4" + peerDependencies: + "@fastify/websocket": ^10 || ^11 + graphql: ^15.10.1 || ^16 + uWebSockets.js: ^20 + ws: ^8 + peerDependenciesMeta: + "@fastify/websocket": + optional: true + uWebSockets.js: + optional: true + ws: + optional: true + checksum: 10c0/ed17502300c702d42820ca2acc593d82acbcbec91fa93e588dc008d07d7b6914b4b22062f1ee181cff6ac62f69ea0052555ee75f270601311b943a6b7ef709dc + languageName: node + linkType: hard + "graphql@npm:16.13.2": version: 16.13.2 resolution: "graphql@npm:16.13.2" @@ -7033,13 +7038,6 @@ __metadata: languageName: node linkType: hard -"iterall@npm:^1.2.1": - version: 1.3.0 - resolution: "iterall@npm:1.3.0" - checksum: 10c0/40de624e5fe937c4c0e511981b91caea9ff2142bfc0316cccc8506eaa03aa253820cc17c5bc5f0a98706c7268a373e5ebee9af9a0c8a359730cf7c05938b57b5 - languageName: node - linkType: hard - "iterator.prototype@npm:^1.1.4": version: 1.1.5 resolution: "iterator.prototype@npm:1.1.5" @@ -10444,21 +10442,6 @@ __metadata: languageName: node linkType: hard -"subscriptions-transport-ws@npm:0.11.0": - version: 0.11.0 - resolution: "subscriptions-transport-ws@npm:0.11.0" - dependencies: - backo2: "npm:^1.0.2" - eventemitter3: "npm:^3.1.0" - iterall: "npm:^1.2.1" - symbol-observable: "npm:^1.0.4" - ws: "npm:^5.2.0 || ^6.0.0 || ^7.0.0" - peerDependencies: - graphql: ^15.7.2 || ^16.0.0 - checksum: 10c0/697441333e59b6932bff51212e29f8dcac477badb067971bd94c30c5f3f7a2e2ea72fb1a21f3c1abbf32774da01515aa24739e620be45f6d576784bd96fd10da - languageName: node - linkType: hard - "supports-color@npm:8.1.1, supports-color@npm:^8.1.1": version: 8.1.1 resolution: "supports-color@npm:8.1.1" @@ -10571,13 +10554,6 @@ __metadata: languageName: node linkType: hard -"symbol-observable@npm:^1.0.4": - version: 1.2.0 - resolution: "symbol-observable@npm:1.2.0" - checksum: 10c0/009fee50798ef80ed4b8195048288f108b03de162db07493f2e1fd993b33fafa72d659e832b584da5a2427daa78e5a738fb2a9ab027ee9454252e0bedbcd1fdc - languageName: node - linkType: hard - "symbol-observable@npm:^4.0.0": version: 4.0.0 resolution: "symbol-observable@npm:4.0.0" @@ -11839,7 +11815,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0, ws@npm:^7.4.6": +"ws@npm:^7.4.6": version: 7.5.10 resolution: "ws@npm:7.5.10" peerDependencies: