Skip to content

Commit

Permalink
feat(realtime): Use promise instead of polling to handle authenticating
Browse files Browse the repository at this point in the history
  • Loading branch information
CPatchane committed Feb 6, 2019
1 parent d3b62e0 commit bdbf440
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 242 deletions.
83 changes: 33 additions & 50 deletions packages/cozy-realtime/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@
// interface, it's a global variable to avoid creating multiple at a time
let cozySocket

// Important, must match the spec,
// see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
const WEBSOCKET_STATE = {
OPEN: 1
}
// Here it is wrapped into a promise to be sure to have it ready on resolved
let socketPromise

const NUM_RETRIES = 3
const RETRY_BASE_DELAY = 1000
Expand All @@ -17,8 +14,9 @@ const RETRY_BASE_DELAY = 1000
// stored as a Map { [doctype]: socket }
let subscriptionsState = new Set()

// getters, for testing
// getters
export const getSubscriptionsState = () => subscriptionsState
export const getSocket = async () => socketPromise && (await socketPromise)
export const getCozySocket = () => cozySocket

// listener key computing, according to doctype only or with doc id
Expand All @@ -42,39 +40,22 @@ const hasListeners = socketListeners => {
}
return false
}
// Send a subscribe message for the given doctype trough the given websocket, but
// only if it is in a ready state. If not, retry a few milliseconds later.
const MAX_SOCKET_POLLS = 500 // to avoid infinite polling
export function subscribeWhenReady(
doctype,
docId,
remainingTries = MAX_SOCKET_POLLS
) {
if (cozySocket.readyState === WEBSOCKET_STATE.OPEN) {
try {
const payload = { type: doctype }
if (docId) payload.id = docId
cozySocket.send(
JSON.stringify({
method: 'SUBSCRIBE',
payload
})
)
} catch (error) {
console.warn(`Cannot subscribe to doctype ${doctype}: ${error.message}`)
throw error
}
} else {
// no retries remaining
if (!remainingTries) {
const error = new Error('socket failed to connect')
console.warn(`Cannot subscribe to doctype ${doctype}: ${error.message}`)
throw error
} else {
setTimeout(() => {
subscribeWhenReady(doctype, docId, --remainingTries)
}, 10)
}

// Send a subscribe message for the given doctype trough the given websocket
export async function subscribeWhenReady(doctype, docId) {
const socket = await getSocket()
try {
const payload = { type: doctype }
if (docId) payload.id = docId
socket.send(
JSON.stringify({
method: 'SUBSCRIBE',
payload
})
)
} catch (error) {
console.warn(`Cannot subscribe to doctype ${doctype}: ${error.message}`)
throw error
}
}

Expand Down Expand Up @@ -155,15 +136,6 @@ export function createWebSocket(
'io.cozy.websocket'
)

socket.onopen = () => {
socket.send(
JSON.stringify({
method: 'AUTH',
payload: options.token
})
)
}

const windowUnloadHandler = () => socket.close()
window.addEventListener('beforeunload', windowUnloadHandler)

Expand All @@ -174,7 +146,17 @@ export function createWebSocket(
}
socket.onerror = error => console.error(`WebSocket error: ${error.message}`)

cozySocket = socket
socketPromise = new Promise(resolve => {
socket.onopen = () => {
socket.send(
JSON.stringify({
method: 'AUTH',
payload: options.token
})
)
resolve(socket)
}
})

if (isRetry && subscriptionsState.size) {
for (let listenerKey of subscriptionsState) {
Expand Down Expand Up @@ -250,7 +232,8 @@ export function initCozySocket(config) {
}, retryDelay)
} else {
console.error(`0 tries left. Stop reconnecting realtime.`)
// remove cached socket
// remove cached socket and promise
if (socketPromise) socketPromise = null
if (cozySocket) cozySocket = null
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`(cozy-realtime) createWebSocket: socket should create and return a cozySocket handling wss 1`] = `
exports[`(cozy-realtime) createWebSocket: socket should create a global socket handling wss 1`] = `
WebSocket {
"binaryType": "blob",
"listeners": Object {
Expand All @@ -18,11 +18,34 @@ WebSocket {
],
},
"protocol": "io.cozy.websocket",
"readyState": 0,
"readyState": 1,
"url": "wss://localhost:8880/realtime/",
}
`;

exports[`(cozy-realtime) createWebSocket: socket should create a global socket with provided domain and secure option 1`] = `
WebSocket {
"binaryType": "blob",
"listeners": Object {
"close": Array [
[Function],
],
"error": Array [
[Function],
],
"message": Array [
[MockFunction],
],
"open": Array [
[Function],
],
},
"protocol": "io.cozy.websocket",
"readyState": 1,
"url": "ws://localhost:8880/realtime/",
}
`;

exports[`(cozy-realtime) createWebSocket: socket should handle authenticating on socket open 1`] = `
Object {
"method": "AUTH",
Expand Down Expand Up @@ -141,7 +164,7 @@ MessageEvent {
],
},
"protocol": "io.cozy.websocket",
"readyState": 0,
"readyState": 1,
"url": "ws://localhost:8880/realtime/",
},
"data": "a server message to socket",
Expand Down Expand Up @@ -181,7 +204,7 @@ MessageEvent {
],
},
"protocol": "io.cozy.websocket",
"readyState": 0,
"readyState": 1,
"url": "ws://localhost:8880/realtime/",
},
"target": WebSocket {
Expand Down Expand Up @@ -213,7 +236,7 @@ MessageEvent {
],
},
"protocol": "io.cozy.websocket",
"readyState": 0,
"readyState": 1,
"url": "ws://localhost:8880/realtime/",
},
"timeStamp": 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`(cozy-realtime) subscribeWhenReady: should retries a provided max number times and throw error + warn if still not opened 1`] = `"socket failed to connect"`;

exports[`(cozy-realtime) subscribeWhenReady: should retries a provided max number times and throw error + warn if still not opened 2`] = `"Cannot subscribe to doctype io.cozy.mocks: socket failed to connect"`;

exports[`(cozy-realtime) subscribeWhenReady: should send the correct socket message if socket opened 1`] = `
Object {
"method": "SUBSCRIBE",
Expand Down
27 changes: 18 additions & 9 deletions packages/cozy-realtime/test/cozySocket.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,19 @@ const mockConfig = {
describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
let mockConnect = jest.fn()
let mockSendSubscribe = jest.fn()
jest.useFakeTimers()

beforeEach(() => {
jest.clearAllMocks()
// reset timeouts
jest.runAllTimers()
// rewire the internal functions usage
__RewireAPI__.__Rewire__('createWebSocket', mockConnect)
__RewireAPI__.__Rewire__('subscribeWhenReady', mockSendSubscribe)
__RewireAPI__.__Rewire__(
'socketPromise',
Promise.resolve(new WebSocket('ws://mock.tools'))
)
__RewireAPI__.__Rewire__('cozySocket', {
subscribe: jest.fn(),
unsubscribe: jest.fn()
Expand All @@ -24,6 +31,7 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
afterEach(() => {
__RewireAPI__.__ResetDependency__('createWebSocket')
__RewireAPI__.__ResetDependency__('subscribeWhenReady')
__RewireAPI__.__ResetDependency__('socketPromise')
__RewireAPI__.__ResetDependency__('cozySocket')
})

Expand Down Expand Up @@ -367,14 +375,13 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
expect(mockConnect.mock.calls.length).toBe(0)
})

it('onSocketClose provided by initCozySocket to createWebSocket should remove the global cozySocket if it exists at the end of retries', () => {
it('onSocketClose provided by initCozySocket to createWebSocket should remove the global socket/cozySocket if it exists at the end of retries', async () => {
cozyRealtime.initCozySocket(mockConfig)
const onSocketClose = mockConnect.mock.calls[0][2]
// reset the mock state to remove the initCozySocket usage
mockConnect.mockReset()
console.warn = jest.fn()
console.error = jest.fn()
jest.useFakeTimers()
onSocketClose(
{
wasClean: false,
Expand All @@ -387,15 +394,17 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
expect(console.error.mock.calls.length).toBe(0)
console.warn.mockClear()
console.error.mockClear()
expect(cozyRealtime.getCozySocket()).toBeInstanceOf(Object)
expect(await cozyRealtime.getCozySocket()).toBeInstanceOf(Object)
expect(await cozyRealtime.getSocket()).toBeInstanceOf(WebSocket)
jest.runAllTimers()
onSocketClose({
wasClean: false,
code: 0,
reason: 'expected test close reason'
})
jest.runAllTimers()
expect(cozyRealtime.getCozySocket()).toBeNull()
expect(await cozyRealtime.getCozySocket()).toBeNull()
expect(await cozyRealtime.getSocket()).toBeNull()
expect(mockConnect.mock.calls.length).toBe(1)
// 2 warns each
expect(console.warn.mock.calls.length).toBe(1)
Expand Down Expand Up @@ -427,6 +436,7 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
expect(console.warn.mock.calls.length).toBe(1)
expect(console.error.mock.calls.length).toBe(1)
expect(mockConnect.mock.calls.length).toBe(0)
// reset
console.warn.mockRestore()
console.error.mockRestore()
})
Expand All @@ -438,7 +448,6 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
let numRetries = RETRIES
// reset the mock state to remove the initCozySocket usage
mockConnect.mockReset()
jest.useFakeTimers()
onSocketClose(
{
wasClean: false,
Expand All @@ -448,7 +457,7 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
numRetries,
200
)
jest.runAllTimers()
jest.runOnlyPendingTimers()
const onSocketClose2 = mockConnect.mock.calls[0][2]
numRetries--
onSocketClose2(
Expand All @@ -460,7 +469,7 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
numRetries,
200
)
jest.runAllTimers()
jest.runOnlyPendingTimers()
// since we have 2 retries here, it shouldn't call createWebSocket
// after this next socket closing
const onSocketClose3 = mockConnect.mock.calls[0][2]
Expand All @@ -474,7 +483,7 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
numRetries,
200
)
jest.runAllTimers()
jest.runOnlyPendingTimers()
expect(mockConnect.mock.calls.length).toBe(RETRIES)
})

Expand All @@ -487,7 +496,6 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
throw mockError
})
__RewireAPI__.__Rewire__('createWebSocket', mockConnectWithError)
jest.useFakeTimers()
console.error = jest.fn()
expect(() => {
onSocketClose(
Expand All @@ -504,6 +512,7 @@ describe('(cozy-realtime) cozySocket handling and initCozySocket: ', () => {
expect(mockConnect.mock.calls.length).toBe(1)
expect(console.error.mock.calls.length).toBe(1)
expect(console.error.mock.calls[0][0]).toMatchSnapshot()
// reset
console.error.mockRestore()
})
})

0 comments on commit bdbf440

Please sign in to comment.