Skip to content

Commit

Permalink
Merge branch 'offline'
Browse files Browse the repository at this point in the history
  • Loading branch information
claustres committed Nov 11, 2024
2 parents 306a5f9 + 6449d7f commit 5890528
Show file tree
Hide file tree
Showing 39 changed files with 3,139 additions and 1,256 deletions.
117 changes: 112 additions & 5 deletions core/client/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@ import jwtdecode from 'jwt-decode'
import feathers from '@feathersjs/client'
import { io } from 'socket.io-client'
import reactive from 'feathers-reactive/dist/feathers-reactive.js'
import createOfflineService, { LocalForage } from '@kalisio/feathers-localforage'
import configuration from 'config'
import { permissions } from '../common/index.js'
import { Store } from './store.js'
import { Events } from './events.js'
import * as hooks from './hooks/index.js'
import { makeServiceSnapshot } from '../common/utils.js'

// Disable default feathers behaviour of reauthenticating on disconnect
feathers.authentication.AuthenticationClient.prototype.handleSocket = () => {}

// Setup log level
if (_.get(configuration, 'logs.level')) {
Expand All @@ -21,6 +27,21 @@ if (_.get(configuration, 'logs.level')) {
export function createClient (config) {
// Initiate the client
const api = feathers()
// Initialize connection state/listeners
api.isDisconnected = !navigator.onLine
addEventListener('online', () => {
api.isDisconnected = false
Events.emit('navigator-reconnected')
})
addEventListener('offline', () => {
api.isDisconnected = true
Events.emit('navigator-disconnected')
})
// This can force to use offline services it they exist even if connected
api.useLocalFirst = config.useLocalFirst
api.setLocalFirstEnabled = function (enabled) {
api.useLocalFirst = enabled
}

// Matchers that can be added to customize route guards
let matchers = []
Expand Down Expand Up @@ -89,14 +110,46 @@ export function createClient (config) {
}
return path
}

api.getOnlineService = function (name, context) {
const servicePath = api.getServicePath(name, context)
const service = api.service(servicePath)
// Store the path on first call
if (service && !service.path) service.path = servicePath
return service
}

api.getOfflineService = function (name, context) {
let servicePath = `${api.getServicePath(name, context)}-offline`
if (servicePath.startsWith('/')) servicePath = servicePath.substr(1)
// We don't directly use service() here as it will create a new service wrapper even if it does not exist
const service = api.services[servicePath]
// Store the path on first call
if (service && !service.path) service.path = servicePath
return service
}

api.getService = function (name, context) {
const path = api.getServicePath(name, context)
const service = api.service(path)
let service
// When offline try to use offline service version if any
if (api.isDisconnected || api.useLocalFirst) {
service = api.getOfflineService(name, context)
// In local first mode we allow to use remote service if offline one doesn't exist
if (!service && !api.useLocalFirst) {
// Do not throw as a lot of components expect services to be available at initialization, eg
// api.getService('xxx').on('event', () => ...)
// In this case we simply warn and return the wrapper to the online service.
// However, it is up to the application to make sure of not using such components when offline
// throw new Error('Cannot retrieve offline service ' + name + ' for context ' + (typeof context === 'object' ? context._id : context))
logger.warn('[KDK] Cannot retrieve offline service ' + name + ' for context ' + (typeof context === 'object' ? context._id : context))
}
}
if (!service) {
throw new Error('Cannot retrieve service ' + name + ' for context ' + (typeof context === 'object' ? context._id : context))
service = api.getOnlineService(name, context)
if (!service) {
throw new Error('Cannot retrieve service ' + name + ' for context ' + (typeof context === 'object' ? context._id : context))
}
}
// Store the path on first call
if (!service.path) service.path = path
return service
}
// Used to register an existing backend service with its options
Expand Down Expand Up @@ -135,6 +188,54 @@ export function createClient (config) {
if (options.context) service.context = options.context
return service
}

// Used to create a frontend only service to be used in offline mode
// based on an online service name, will snapshot service data by default
api.createOfflineService = async function (serviceName, options = {}) {
const offlineServiceName = `${serviceName}-offline`
let offlineService = api.getOfflineService(serviceName)

if (!offlineService) {
// Pass options not used internally for offline management as service options and store it along with service
const serviceOptions = _.omit(options, ['hooks', 'snapshot', 'clear', 'baseQuery', 'baseQueries', 'dataPath'])
const services = await LocalForage.getItem('services') || {}
_.set(services, serviceName, serviceOptions)
await LocalForage.setItem('services', services)
offlineService = api.createService(offlineServiceName, {
service: createOfflineService({
id: '_id',
name: 'offline_services',
storeName: serviceName,
multi: true,
storage: ['IndexedDB'],
// FIXME: this should not be hard-coded as it depends on the service
// For now we set it at the max value but if a component
// does not explicitely set the limit it will get a lot of data
paginate: { default: 5000, max: 5000 }
}),
// Set required default hooks
hooks: _.defaultsDeep(_.get(options, 'hooks'), {
before: {
all: [hooks.ensureSerializable, hooks.removeServerSideParameters],
create: [hooks.generateId]
}
}),
...serviceOptions
})
}

if (_.get(options, 'snapshot', true)) {
const service = api.getOnlineService(serviceName)
await makeServiceSnapshot(service, Object.assign({ offlineService }, options))
}

return offlineService
}
api.removeService = function (name, context) {
let path = api.getServicePath(name, context)
if (path.startsWith('/')) path = path.substr(1)
api.unuse(path)
}
// Helper fonctions to access/alter config used at creation time
api.getConfig = function (path) {
return (path ? _.get(config, path) : config)
Expand Down Expand Up @@ -215,6 +316,12 @@ export function createClient (config) {
api.configure(api.transporter)
// Retrieve our specific errors on rate-limiting
api.socket.on('rate-limit', (error) => Events.emit('error', error))
// Disable default socketio behaviour of buffering messages when disconnected
api.socket.io.on('reconnect', function () {
api.socket.sendBuffer = []
// Reauthenticate on reconnect
api.reAuthenticate(true)
})
}
api.configure(feathers.authentication({
storage: window.localStorage,
Expand Down
21 changes: 15 additions & 6 deletions core/client/capabilities.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import _ from 'lodash'
import logger from 'loglevel'
import config from 'config'
import { LocalForage } from '@kalisio/feathers-localforage'
import { api } from './api.js'
import { Store } from './store.js'

// Export singleton
export const Capabilities = {
async initialize () {
const capabilities = await window.fetch(api.getConfig('domain') + _.get(config, 'apiPath') + '/capabilities')
const content = await capabilities.json()
logger.debug('[KDK] Fetched capabilities:', content)
this.content = content
if (api.isDisconnected || api.useLocalFirst) {
this.content = await LocalForage.getItem('capabilities')
}
if (!this.content) {
const capabilities = await window.fetch(api.getConfig('domain') + _.get(config, 'apiPath') + '/capabilities')
const content = await capabilities.json()
logger.debug('[KDK] Fetched capabilities:', content)
this.content = content
// Store latest capabilities data for offline mode
await LocalForage.setItem('capabilities', content)
}
if (!this.content) return
// Backend might override some defaults in client config
_.forOwn(_.pick(content, ['gateway']), (value, key) => {
_.forOwn(_.pick(this.content, ['gateway']), (value, key) => {
api.setConfig(key, value)
})
// Used to ensure backward compatibility
Store.set('capabilities.api', content)
Store.set('capabilities.api', this.content)
Store.set('capabilities.client', _.pick(config, ['version', 'buildNumber']))
},
get (key, defaultValue) {
Expand Down
2 changes: 1 addition & 1 deletion core/client/composables/collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function useCollection (options) {
// In this case we need to reset the full collection as Rx only tracks changes on the current page
updatedItems = (Array.isArray(updatedItems) ? updatedItems : [updatedItems])
// We keep order from the updated list as depending on the sorting criteria a new item might have to be pushed on top of current items
updatedItems = _.intersectionWith(items.value, updatedItems, (item1, item2) => (item1._id.toString() === item2._id.toString()))
updatedItems = _.intersectionWith(items.value, updatedItems, (item1, item2) => item1._id && item2._id && (item1._id.toString() === item2._id.toString()))
if (updatedItems.length > 0) resetCollection()
}

Expand Down
17 changes: 12 additions & 5 deletions core/client/composables/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,18 @@ export function useSession (options = {}) {
}
// Display it only the first time the error appears because multiple attempts will be tried
if (!pendingReconnection && !ignoreReconnectionError) {
Events.emit('disconnected')
api.isDisconnected = true
Events.emit('websocket-disconnected')
logger.error(new Error('Socket has been disconnected'))
// Disconnect prompt can be avoided, eg in tests
if (!LocalStorage.get(disconnectKey, true)) return
// This will ensure any operation in progress will not keep a "dead" loading indicator
// as this error might appear under-the-hood without notifying service operations
Loading.hide()
// Disconnect prompt can be avoided, eg in tests
if (!LocalStorage.get(disconnectKey, true)) {
// We flag however that we are waiting for reconnection to avoid emitting the event multiple times
pendingReconnection = true
return
}
pendingReconnection = $q.dialog({
title: i18n.t('composables.session.ALERT'),
message: i18n.t('composables.session.DISCONNECT'),
Expand Down Expand Up @@ -115,13 +120,15 @@ export function useSession (options = {}) {
function onReconnect () {
// Dismiss pending reconnection error message if any
if (pendingReconnection) {
pendingReconnection.hide()
// If reconnection prompt is avoided we simply have a boolean flag instead of a dismiss dialog function
if (typeof pendingReconnection.hide === 'function') pendingReconnection.hide()
pendingReconnection = null
}
ignoreReconnectionError = false
// Display it only the first time the reconnection occurs because multiple attempts will be tried
if (!pendingReload) {
Events.emit('reconnected')
api.isDisconnected = false
Events.emit('websocket-reconnected')
// Reconnect prompt can be avoided, eg in tests
if (!LocalStorage.get(reconnectKey, true)) return
pendingReload = $q.dialog({
Expand Down
32 changes: 32 additions & 0 deletions core/client/hooks/hooks.offline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import _ from 'lodash'
import { uid } from 'quasar'

export function removeServerSideParameters(context) {
const params = context.params
_.unset(params, 'query.$locale')
_.unset(params, 'query.$collation')
_.unset(params, 'query.populate')
if (_.has(params, 'query.upsert')) {
_.set(params, 'upsert', _.get(params, 'query.upsert'))
_.unset(params, 'query.upsert')
}
}

export function generateId(context) {
const params = context.params
// Generate ID only when not doing a snapshot because it should keep data as it is on the server side
// Typically some services like the catalog deliver objects without any IDs (as directly coming from the configuration not the database)
// and this property is used to make some difference in the way the GUI interpret this objects
if (params.snapshot) return
const data = (Array.isArray(context.data) ? context.data : [context.data])
// Update only items without any ID
data.filter(item => !item._id).forEach(item => {
item._id = uid().toString()
})
}

export function ensureSerializable(context) {
// Serialization in localforage will raise error with structures that are not raw JSON:
// JS proxy objects, function properties, etc.
if (context.data) context.data = _.cloneDeep(context.data)
}
1 change: 1 addition & 0 deletions core/client/hooks/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './hooks.logger.js'
export * from './hooks.events.js'
export * from './hooks.offline.js'
export * from './hooks.users.js'
7 changes: 5 additions & 2 deletions core/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Store } from './store.js'
import { Theme } from './theme.js'
import { Capabilities } from './capabilities.js'
import { LocalStorage } from './local-storage.js'
import { LocalCache } from './local-cache.js'
import { Storage } from './storage.js'
import { TemplateContext } from './template-context.js'
import { Time } from './time.js'
Expand All @@ -30,6 +31,7 @@ export { Store }
export { Theme }
export { Capabilities }
export { LocalStorage }
export { LocalCache }
export { Storage }
export { TemplateContext }
export { Time }
Expand Down Expand Up @@ -61,6 +63,9 @@ export default async function initialize () {
Store.set('kdk', { core: { initialized: false }, map: { initialized: false } })

// Initialize singletons that might be used globally first
LocalStorage.initialize()
LocalCache.initialize()
Storage.initialize()
Theme.initialize()
Time.initialize()
Units.initialize()
Expand All @@ -70,8 +75,6 @@ export default async function initialize () {
// Last, create the models listened by the main layout/pages components
// You must use the patch method on the store to update those models
// It is generally done by activity based componentq or through a local settings service
LocalStorage.initialize()
Storage.initialize()
Layout.initialize()
Filter.initialize()
Sorter.initialize()
Expand Down
57 changes: 57 additions & 0 deletions core/client/local-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import logger from 'loglevel'
import _ from 'lodash'
import { LocalForage } from '@kalisio/feathers-localforage'

export const LocalCache = {
initialize () {
logger.debug('[KDK] initializing local cache')
LocalForage.config({
name: 'offline_cache',
storeName: 'cache_entries'
})
},
async createCache (cacheName) {
const cache = await caches.open(cacheName)
return cache
},
async getCache (cacheName) {
const cache = await caches.open(cacheName)
return cache
},
async removeCache (cacheName) {
await caches.delete(cacheName)
},
async has (key) {
return !_.isNil(this.getCount(key))
},
async getCount (key) {
return await LocalForage.getItem(key)
},
async setCount (key, count) {
await LocalForage.setItem(key, count)
},
async set (cacheName, key, url, fetchOptions = {}) {
const count = await this.getCount(key)
if (!_.isNil(count)) {
await this.setCount(key, count + 1)
} else {
const cache = await this.getCache(cacheName)
let response = await fetch(url, fetchOptions)
// Convert response from 206 -> 200 to make it cacheable
if (response.status === 206) response = new Response(response.body, { status: 200, headers: response.headers })
await cache.put(key, response)
await LocalForage.setItem(key, 1)
}
},
async unset (cacheName, key) {
const cache = await this.getCache(cacheName)
const count = await this.getCount(key)
if (_.isNil(count)) return
if (count <= 1) {
cache.delete(key)
await LocalForage.removeItem(key)
} else {
await this.setCount(key, count - 1)
}
}
}
4 changes: 4 additions & 0 deletions core/client/local-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export const LocalStorage = {
const jsonValue = JSON.stringify(value)
window.localStorage.setItem(this.localKey(key), jsonValue)
},
has (key) {
const value = window.localStorage.getItem(this.localKey(key))
return !_.isNil(value)
},
get (key, defaultValue) {
const value = window.localStorage.getItem(this.localKey(key))
if (_.isNil(value)) {
Expand Down
Loading

0 comments on commit 5890528

Please sign in to comment.