Skip to content

Commit

Permalink
refactor(realtime): ✨ Remove async + use simpler API
Browse files Browse the repository at this point in the history
BREAKING CHANGES: New and simpler API + no more async usage

Also use doc id specific realtime messages instead of listener like before, fix #145
  • Loading branch information
CPatchane committed Feb 5, 2019
1 parent 44acfde commit 65e5a16
Show file tree
Hide file tree
Showing 12 changed files with 453 additions and 175 deletions.
2 changes: 1 addition & 1 deletion CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
packages/cozy-device-helper @kosssi @ptbrowne
packages/cozy-flags @ptbrowne
packages/cozy-realtime @gregorylegarec @kosssi
packages/cozy-realtime @gregorylegarec @cpatchane
packages/babel-preset-cozy-app @cpatchane
packages/eslint-config-cozy-app @cpatchane
packages/commitlint-config-cozy @kosssi @enguerran
Expand Down
48 changes: 20 additions & 28 deletions packages/cozy-realtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,24 +40,31 @@ or

## API

#### `subscribeAll(config, doctype, parse)`
#### `subscribe(config, doctype, options)`

This method allow you to subscribe to realtime for all documents of a provided doctype. Here are the parameters:
This method allow you to subscribe to realtime for one document or all documents of a provided doctype. Here are the parameters:

- `config`: a config object with the following keys :
- `domain`: the instance domain (ex: `cozy.works`, `cozy.tools:8080`), must be provided if `url` is not set
- `token`: the cozy token (ex: the global `cozy.client._token.token`)
- `url`: the cozy url (ex: https://recette.cozy.works), must be provided if `domain` is not set
- `url`: the cozy url (ex: https://recette.cozy.works)
- `secure`: a boolean indicating if a secure protocol must be used (default `true`). Should not be provided along `url`.
- `doctype`: the doctype to subscribe (ex: `io.cozy.accounts`)
- `parse`: a custom function to be use as parser for your resulting documents (default: `doc => doc`)
- `options`: an object to use optional parameters:
- `docId`: a document `_id` attribute to target in order to get realtime only for this specific document
- `parse`: a custom function to be use as parser for your resulting documents (default: `doc => doc`)

Here is an example:

```javascript
import realtime from 'cozy-realtime'

const subscription = await realtime.subscribeAll(cozy.client, 'io.mocks.mydocs')
const config = {
token: authToken, // app token provided by the stack or the client
domain: 'cozy.tools:8080',
secure: true // to use wss (with SSL) or not
}
const subscription = realtime.subscribe(config, 'io.mocks.mydocs')

// your code when a new document is created
subscription.onCreate(doc => doSomethingOnCreate(doc))
Expand All @@ -68,40 +75,25 @@ subscription.onDelete(doc => doSomethingOnDelete(doc))

// Unsubscribe from realtime
subscription.unsubscribe()
```

#### `subscribe(config, doctype, doc, parse)`

This method is exactly the same working as the previous `subscribeAll()` function but to listen only one document. Here are the parameters:

- `config`: a config object with the following keys :
- `domain`: the instance domain (ex: `cozy.works`, `cozy.tools:8080`), must be provided if `url` is not set
- `token`: the cozy token (ex: the global `cozy.client._token.token`)
- `url`: the cozy url (ex: https://recette.cozy.works)
- `secure`: a boolean indicating if a secure protocol must be used (default `true`). Should not be provided along `url`.
- `doctype`: the doctype to subscribe (ex: `io.cozy.accounts`)
- `doc`: the document to listen, it must be at least a JS object with the `_id` attribute of the wanted document (only the `_id` will be checked to know if this is the wanted document or not).
- `parse`: a custom function to be use as parser for your resulting documents (default: `doc => doc`)

Here is an example:
// for a specific document
const docSubscription = realtime.subscribe(config, 'io.mocks.mydocs')

```javascript
import realtime from 'cozy-realtime'

const subscription = await realtime.subscribe(cozy.client, 'io.mocks.mydocs', myDoc)
// There is no onCreate here since to have the id,
// the document is already created

// your code when your document is updated
subscription.onUpdate(doc => doSomethingOnUpdate(doc))
docSubscription.onUpdate(doc => doSomethingOnUpdate(doc))
// your code when your document is deleted
subscription.onDelete(doc => doSomethingOnDelete(doc))
docSubscription.onDelete(doc => doSomethingOnDelete(doc))

// Unsubscribe from realtime
subscription.unsubscribe()
docSubscription.unsubscribe()
```

### Maintainers

The maintainers for Cozy Realtime are [Greg](https://github.com/gregorylegarec) and [kosssi](https://github.com/kosssi) !
The maintainers for Cozy Realtime are [Greg](https://github.com/gregorylegarec) and [CPatchane](https://github.com/CPatchane) !

## License

Expand Down
7 changes: 7 additions & 0 deletions packages/cozy-realtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,12 @@
"babel-preset-cozy-app": "^1.3.0",
"jest-cli": "^24.0.0",
"mock-socket": "^8.0.5"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.js",
"!<rootDir>/node_modules/",
"!<rootDir>/test/"
]
}
}
136 changes: 78 additions & 58 deletions packages/cozy-realtime/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,36 @@ let subscriptionsState = new Set()

export const getSubscriptionsState = () => subscriptionsState

// listener key computing, according to doctype only or with doc id
const LISTENER_KEY_SEPARATOR = '/' // safe since we can't have a '/' in a doctype
const getListenerKey = (doctype, docId) =>
docId ? [doctype, docId].join(LISTENER_KEY_SEPARATOR) : doctype

const getTypeAndIdFromListenerKey = listenerKey => {
const splitResult = listenerKey.split(LISTENER_KEY_SEPARATOR)
return {
doctype: splitResult[0],
docId: splitResult.length > 1 ? splitResult[1] : null
}
}

// 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 poling
export function subscribeWhenReady(
doctype,
socket,
docId,
remainedTries = MAX_SOCKET_POLLS
) {
if (socket.readyState === WEBSOCKET_STATE.OPEN) {
try {
const payload = { type: doctype }
if (docId) payload.id = docId
socket.send(
JSON.stringify({
method: 'SUBSCRIBE',
payload: {
type: doctype
}
payload
})
)
} catch (error) {
Expand All @@ -49,7 +63,7 @@ export function subscribeWhenReady(
throw error
} else {
setTimeout(() => {
subscribeWhenReady(doctype, socket, --remainedTries)
subscribeWhenReady(doctype, socket, docId, --remainedTries)
}, 10)
}
}
Expand Down Expand Up @@ -156,16 +170,17 @@ export function connectWebSocket(
socket.onerror = error => console.error(`WebSocket error: ${error.message}`)

if (isRetry && subscriptionsState.size) {
for (let doctype of subscriptionsState) {
subscribeWhenReady(doctype, socket)
for (let listenerKey of subscriptionsState) {
const { doctype, docId } = getTypeAndIdFromListenerKey(listenerKey)
subscribeWhenReady(doctype, socket, docId)
}
}

return socket
}

export function getCozySocket(config) {
const listeners = {}
const listeners = new Map()

let socket

Expand All @@ -184,8 +199,21 @@ export function getCozySocket(config) {
throw realtimeError
}

if (listeners[payload.type] && listeners[payload.type][eventType]) {
listeners[payload.type][eventType].forEach(listener => {
// the payload should always have an id here
const listenerKey = getListenerKey(payload.type, payload.id)

// id listener call
if (listeners.has(listenerKey) && listeners.get(listenerKey)[eventType]) {
listeners.get(listenerKey)[eventType].forEach(listener => {
listener(payload.doc)
})
}

if (listenerKey === payload.type) return

// doctype listner call
if (listeners.has(payload.type) && listeners.get(payload.type)[eventType]) {
listeners.get(payload.type)[eventType].forEach(listener => {
listener(payload.doc)
})
}
Expand Down Expand Up @@ -234,61 +262,47 @@ export function getCozySocket(config) {
}

return {
subscribe: (doctype, event, listener) => {
subscribe: (doctype, event, listener, docId) => {
if (typeof listener !== 'function')
throw new Error('Realtime event listener must be a function')

if (!listeners[doctype]) {
listeners[doctype] = {}
subscribeWhenReady(doctype, socket)
const listenerKey = getListenerKey(doctype, docId)

if (!listeners.has(listenerKey)) {
listeners.set(listenerKey, {})
subscribeWhenReady(doctype, socket, docId)
}

listeners[doctype][event] = (listeners[doctype][event] || []).concat([
listener
])
listeners.set(listenerKey, {
...listeners.get(listenerKey),
[event]: [listener]
})

if (!subscriptionsState.has(doctype)) {
subscriptionsState.add(doctype)
if (!subscriptionsState.has(listenerKey)) {
subscriptionsState.add(listenerKey)
}
},
unsubscribe: (doctype, event, listener) => {
unsubscribe: (doctype, event, listener, docId) => {
const listenerKey = getListenerKey(doctype, docId)
if (
listeners[doctype] &&
listeners[doctype][event] &&
listeners[doctype][event].includes(listener)
listeners.has(listenerKey) &&
listeners.get(listenerKey)[event] &&
listeners.get(listenerKey)[event].includes(listener)
) {
listeners[doctype][event] = listeners[doctype][event].filter(
l => l !== listener
)
listeners.set(listenerKey, {
...listeners.get(listenerKey),
[event]: listeners.get(listenerKey)[event].filter(l => l !== listener)
})
}
if (subscriptionsState.has(doctype)) {
subscriptionsState.delete(doctype)
if (subscriptionsState.has(listenerKey)) {
subscriptionsState.delete(listenerKey)
}
}
}
}

// Returns the Promise of a subscription to a given doctype and document
export function subscribe(config, doctype, doc, parse = doc => doc) {
const subscription = subscribeAll(config, doctype, parse)
// We will call the listener only for the given document, so let's curry it
const docListenerCurried = listener => {
return syncedDoc => {
if (syncedDoc._id === doc._id) {
listener(syncedDoc)
}
}
}

return {
onUpdate: listener => subscription.onUpdate(docListenerCurried(listener)),
onDelete: listener => subscription.onDelete(docListenerCurried(listener)),
unsubscribe: () => subscription.unsubscribe()
}
}

// Returns the Promise of a subscription to a given doctype (all documents)
export function subscribeAll(config, doctype, parse = doc => doc) {
export function subscribe(config, doctype, { docId, parse = doc => doc } = {}) {
if (!cozySocket) cozySocket = getCozySocket(config)
// Some document need to have specific parsing, for example, decoding
// base64 encoded properties
Expand All @@ -298,35 +312,41 @@ export function subscribeAll(config, doctype, parse = doc => doc) {
}
}

const subscribeAllDocs = !docId

let createListener, updateListener, deleteListener

const subscription = {
onCreate: listener => {
createListener = parseCurried(listener)
cozySocket.subscribe(doctype, 'created', createListener)
return subscription
},
onUpdate: listener => {
updateListener = parseCurried(listener)
cozySocket.subscribe(doctype, 'updated', updateListener)
cozySocket.subscribe(doctype, 'updated', updateListener, docId)
return subscription
},
onDelete: listener => {
deleteListener = parseCurried(listener)
cozySocket.subscribe(doctype, 'deleted', deleteListener)
cozySocket.subscribe(doctype, 'deleted', deleteListener, docId)
return subscription
},
unsubscribe: () => {
cozySocket.unsubscribe(doctype, 'created', createListener)
cozySocket.unsubscribe(doctype, 'updated', updateListener)
cozySocket.unsubscribe(doctype, 'deleted', deleteListener)
if (subscribeAllDocs) {
cozySocket.unsubscribe(doctype, 'created', createListener)
}
cozySocket.unsubscribe(doctype, 'updated', updateListener, docId)
cozySocket.unsubscribe(doctype, 'deleted', deleteListener, docId)
}
}

if (subscribeAllDocs) {
subscription.onCreate = listener => {
createListener = parseCurried(listener)
cozySocket.subscribe(doctype, 'created', createListener)
return subscription
}
}

return subscription
}

export default {
subscribeAll,
subscribe
}

0 comments on commit 65e5a16

Please sign in to comment.