Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ networkSettings:

nodeLinker: node-modules

npmMinimalAgeGate: 0

yarnPath: .yarn/releases/yarn-4.17.1.cjs
1 change: 1 addition & 0 deletions changes.d/2237.feat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Changed to `graphql-transport-ws` subprotocol for WebSocket connections to the UI server.
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cylc-ui",
"version": "2.15.0-0",
"version": "3.0.0-0",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
Expand Down Expand Up @@ -36,13 +36,13 @@
"graphiql": "4.1.2",
"graphql": "16.14.2",
"graphql-tag": "2.12.7",
"graphql-ws": "6.0.4",
"lodash-es": "4.18.1",
"markdown-it": "14.2.0",
"mitt": "3.0.1",
"nprogress": "1.0.0-1",
"preact": "10.27.3",
"simple-icons": "16.24.1",
"subscriptions-transport-ws": "0.11.0",
"svg-pan-zoom": "3.6.2",
"vue": "3.5.39",
"vue-i18n": "11.4.6",
Expand All @@ -59,7 +59,6 @@
"@vitest/coverage-istanbul": "4.1.9",
"@vue/test-utils": "2.4.11",
"concurrently": "9.2.4",
"cross-fetch": "4.1.0",
"cypress": "15.18.0",
"eslint": "8.57.1",
"eslint-config-standard": "17.1.0",
Expand Down
1 change: 0 additions & 1 deletion renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,6 @@
"groupName": "tests packages",
"matchPackageNames": [
"@vue/test-utils",
"cross-fetch",
"jsdom",
"nyc",
"sinon",
Expand Down
72 changes: 29 additions & 43 deletions src/graphql/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**
/*
* Copyright (C) Earth Sciences New Zealand & British Crown (Met Office) & Contributors.
*
* This program is free software: you can redistribute it and/or modify
Expand All @@ -15,21 +15,20 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { SubscriptionClient } from 'subscriptions-transport-ws'
import { createClient } from 'graphql-ws'
import {
ApolloClient,
ApolloLink,
HttpLink,
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.
Expand All @@ -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
}

/**
Expand All @@ -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) {
Expand All @@ -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(
Expand Down
63 changes: 28 additions & 35 deletions src/services/mock/websockets.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,55 +15,48 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

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}`)
}
Expand Down
2 changes: 1 addition & 1 deletion src/services/workflow.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
2 changes: 0 additions & 2 deletions tests/unit/services/workflow.service.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import { vi } from 'vitest'
import sinon from 'sinon'
import { print } from 'graphql/language'
import gql from 'graphql-tag'
// need the polyfill as otherwise ApolloClient fails to be imported as it checks for a global fetch object on import...
import 'cross-fetch/polyfill'
import Subscription from '@/model/Subscription.model'
import SubscriptionQuery from '@/model/SubscriptionQuery.model'
import WorkflowService from '@/services/workflow.service'
Expand Down
2 changes: 0 additions & 2 deletions tests/unit/utils/aotf.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
import { afterAll, beforeAll } from 'vitest'
import * as aotf from '@/utils/aotf'
import dedent from 'dedent'
// need the polyfill as otherwise ApolloClient fails to be imported as it checks for a global fetch object on import...
import 'cross-fetch/polyfill'
import sinon from 'sinon'

describe('aotf (Api On The Fly)', () => {
Expand Down
94 changes: 23 additions & 71 deletions tests/unit/utils/graphql.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,82 +15,34 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

// need the polyfill as otherwise ApolloClient fails to be imported as it checks for a global fetch object on import...
import 'cross-fetch/polyfill'
import * as graphql from '@/graphql'
import { store } from '@/store/index'
import storeOptions from '@/store/options'
import { describe, it, expect } from 'vitest'
import {
createApolloClient,
createGraphQLUrls,
createSubscriptionClient
} from '@/graphql'

describe('utils', () => {
describe('graphql', () => {
describe('ApolloClient', () => {
it('should create an apollo client', () => {
const apolloClient = graphql.createApolloClient('http://localhost:12345', null)
expect(apolloClient.link !== null).to.equal(true)
expect(apolloClient.cache !== null).to.equal(true)
})
describe('graphql', () => {
describe('ApolloClient', () => {
it('should create an apollo client', () => {
const apolloClient = createApolloClient('http://localhost:12345', null)
expect(apolloClient.link).toBeTruthy()
expect(apolloClient.cache).toBeTruthy()
})
})

describe('SubscriptionClient', () => {
beforeEach(() => {
store.replaceState(storeOptions.state())
expect(store.state.offline).to.be.false
})
it('should create a subscription client', () => {
const subscriptionClient = graphql.createSubscriptionClient(
'ws://localhost:12345',
{
reconnect: false,
lazy: true
},
{})
expect(typeof subscriptionClient.request).to.equal('function')
})
it('should call the subscription client callbacks', () => {
const subscriptionClient = graphql.createSubscriptionClient(
'ws://localhost:12345',
{
reconnect: false,
lazy: true
},
{})
expect(store.state.offline).to.equal(false)

let eventName, expectedOffline
for ({ eventName, expectedOffline } of [
{
eventName: 'connecting',
expectedOffline: true
},
{
eventName: 'connected',
expectedOffline: false
},
{
eventName: 'reconnecting',
expectedOffline: true
},
{
eventName: 'reconnected',
expectedOffline: false
},
{
eventName: 'disconnected',
expectedOffline: true
}
]) {
subscriptionClient.eventEmitter.emit(eventName)
expect(store.state.offline).to.equal(expectedOffline)
}
})
describe('SubscriptionClient', () => {
it('creates a subscription client', () => {
const subscriptionClient = createSubscriptionClient('ws://localhost:12345')
expect(subscriptionClient.on).toBeTypeOf('function')
})
})

describe('GraphQL URLs', () => {
it('should create the correct URLs', () => {
const graphQLUrls = graphql.createGraphQLUrls()
expect(graphQLUrls.httpUrl.slice(0, 4)).to.equal('http')
expect(graphQLUrls.wsUrl.slice(0, 2)).to.equal('ws')
})
describe('GraphQL URLs', () => {
it('should create the correct URLs', () => {
const graphQLUrls = createGraphQLUrls()
expect(graphQLUrls.httpUrl.slice(0, 4)).to.equal('http')
expect(graphQLUrls.wsUrl.slice(0, 2)).to.equal('ws')
})
})
})
Loading