From 1dfd1e6249fa22412eb87ea8e693d908af835498 Mon Sep 17 00:00:00 2001 From: Kai Vandivier <49666798+KaiVandivier@users.noreply.github.com> Date: Fri, 3 Mar 2023 11:22:31 +0100 Subject: [PATCH] feat(pwa): track online status [LIBS-315] (#718) * fix: migrate to IDB to store dhis2 base url * refactor: init function * fix: add complete env to dev SW * refactor: get base url api * feat(pwa): use SW to track dhis2 server connection (most strategies) * refactor(sw): convert recording mode to strategy & use status plugin * refactor: rename online status to dhis2 connection status for clarity * fix(deps): use latest app-runtime * chore(pwa-app): add request tester * feat(offline-interface): add subscription to connection status from SW * chore(pwa-app): temporarily remove dependency * fix: fallback to localStorage for baseUrl to avoid breaking change * chore: format * chore: formatting * chore: add some tools for testing connection status * fix: exclude pings from cache * fix: treat 401 as disconnected * fix: don't check auth * refactor: make reusable getAllClientsInScope fn * refactor: clean up * fix: throttle status broadcasts * fix: use shorter isConnected name * chore: rename set-up-service-worker * fix: change subscription callback name * chore: some more dev utils * chore: satisfy linter * chore: update comments * refactor: clarify systemInfo and baseUrl states * chore: clarify todos * chore: update comment * fix: wait for offline interface to be ready for connection status * chore: fix typo * chore: update temp test scripts * chore: remove console logs * chore: clear up test scripts * fix(sw): use NetworkOnly for new ping endpoint * feat: use connection status from app runtime --- adapter/src/components/LoginModal.js | 16 ++- .../src/components/ServerVersionProvider.js | 118 +++++++++++++++--- cli/src/lib/pwa/compileServiceWorker.js | 11 +- examples/pwa-app/d2.config.js | 3 +- examples/pwa-app/src/App.js | 2 + .../pwa-app/src/components/RequestTester.js | 53 ++++++++ pwa/src/index.js | 3 +- pwa/src/lib/base-url-db.js | 48 +++++++ pwa/src/lib/constants.js | 3 + .../offline-interface/offline-interface.js | 39 ++++++ .../service-worker/dhis2-connection-status.js | 73 +++++++++++ pwa/src/service-worker/recording-mode.js | 37 ++++-- ...ice-worker.js => set-up-service-worker.js} | 50 ++++++-- pwa/src/service-worker/utils.js | 27 ++-- shell/package.json | 2 +- shell/src/App.js | 4 +- yarn.lock | 56 ++++----- 17 files changed, 455 insertions(+), 90 deletions(-) create mode 100644 examples/pwa-app/src/components/RequestTester.js create mode 100644 pwa/src/lib/base-url-db.js create mode 100644 pwa/src/service-worker/dhis2-connection-status.js rename pwa/src/service-worker/{service-worker.js => set-up-service-worker.js} (84%) diff --git a/adapter/src/components/LoginModal.js b/adapter/src/components/LoginModal.js index cb5c2d136..1b7fdbced 100644 --- a/adapter/src/components/LoginModal.js +++ b/adapter/src/components/LoginModal.js @@ -1,3 +1,4 @@ +import { setBaseUrlByAppName } from '@dhis2/pwa' import { Modal, ModalTitle, @@ -6,16 +7,16 @@ import { Button, InputField, } from '@dhis2/ui' +import PropTypes from 'prop-types' import React, { useState } from 'react' import i18n from '../locales/index.js' import { post } from '../utils/api.js' +// Check if base URL is set statically as an env var (typical in production) const staticUrl = process.env.REACT_APP_DHIS2_BASE_URL -export const LoginModal = () => { - const [server, setServer] = useState( - staticUrl || window.localStorage.DHIS2_BASE_URL || '' - ) +export const LoginModal = ({ appName, baseUrl }) => { + const [server, setServer] = useState(baseUrl || '') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [isDirty, setIsDirty] = useState(false) @@ -27,7 +28,10 @@ export const LoginModal = () => { setIsDirty(true) if (isValid(server) && isValid(username) && isValid(password)) { if (!staticUrl) { + // keep the localStorage value here -- it's still used in some + // obscure cases, like in the cypress network shim window.localStorage.DHIS2_BASE_URL = server + await setBaseUrlByAppName({ appName, baseUrl: server }) } try { await post( @@ -99,3 +103,7 @@ export const LoginModal = () => { ) } +LoginModal.propTypes = { + appName: PropTypes.string, + baseUrl: PropTypes.string, +} diff --git a/adapter/src/components/ServerVersionProvider.js b/adapter/src/components/ServerVersionProvider.js index 6a76f86cc..71a6006fd 100644 --- a/adapter/src/components/ServerVersionProvider.js +++ b/adapter/src/components/ServerVersionProvider.js @@ -1,4 +1,5 @@ import { Provider } from '@dhis2/app-runtime' +import { getBaseUrlByAppName, setBaseUrlByAppName } from '@dhis2/pwa' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' import { get } from '../utils/api.js' @@ -10,45 +11,134 @@ import { useOfflineInterface } from './OfflineInterfaceContext.js' export const ServerVersionProvider = ({ appName, appVersion, - url, + url, // url from env vars apiVersion, pwaEnabled, children, }) => { const offlineInterface = useOfflineInterface() - const [{ loading, error, systemInfo }, setState] = useState({ + const [systemInfoState, setSystemInfoState] = useState({ loading: true, + error: undefined, + systemInfo: undefined, }) + const [baseUrlState, setBaseUrlState] = useState({ + loading: !url, + error: undefined, + baseUrl: url, + }) + const [offlineInterfaceLoading, setOfflineInterfaceLoading] = useState(true) + const { systemInfo } = systemInfoState + const { baseUrl } = baseUrlState useEffect(() => { - if (!url) { - setState({ loading: false, error: new Error('No url specified') }) + // if URL prop is not set, set state to error to show login modal. + // Submitting valid login form with server and credentials reloads page, + // ostensibly with a filled url prop (now persisted locally) + if (!baseUrl) { + // Use a function as the argument to avoid needing baseUrlState as + // a dependency for useEffect + setBaseUrlState((state) => + state.loading + ? state + : { loading: true, error: undefined, systemInfo: undefined } + ) + // try getting URL from IndexedDB + getBaseUrlByAppName(appName) + .then((baseUrlFromDB) => { + if (baseUrlFromDB) { + // Set baseUrl in state if found in DB + setBaseUrlState({ + loading: false, + error: undefined, + baseUrl: baseUrlFromDB, + }) + return + } + // If no URL found in DB, try localStorage + // (previous adapter versions stored the base URL there) + const baseUrlFromLocalStorage = + window.localStorage.DHIS2_BASE_URL + if (baseUrlFromLocalStorage) { + setBaseUrlState({ + loading: false, + error: undefined, + baseUrl: baseUrlFromLocalStorage, + }) + // Also set it in IndexedDB for SW to access + return setBaseUrlByAppName({ + appName, + baseUrl: baseUrlFromLocalStorage, + }) + } + // If no base URL found in either, set error to show login modal + setBaseUrlState({ + loading: false, + error: new Error('No url specified'), + baseUrl: undefined, + }) + }) + .catch((err) => { + console.error(err) + setBaseUrlState({ + loading: false, + error: err, + baseUrl: undefined, + }) + }) + return } - setState((state) => (state.loading ? state : { loading: true })) - const request = get(`${url}/api/system/info`) + // If url IS set, try querying API to test authentication and get + // server version. If it fails, set error to show login modal + + setSystemInfoState((state) => + state.loading + ? state + : { loading: true, error: undefined, systemInfo: undefined } + ) + const request = get(`${baseUrl}/api/system/info`) request .then((systemInfo) => { - setState({ loading: false, systemInfo }) + setSystemInfoState({ + loading: false, + error: undefined, + systemInfo: systemInfo, + }) }) .catch((e) => { // Todo: If this is a network error, the app cannot load -- handle that gracefully here // if (e === 'Network error') { ... } - setState({ loading: false, error: e }) + setSystemInfoState({ + loading: false, + error: e, + systemInfo: undefined, + }) }) return () => { request.abort() } - }, [url]) + }, [appName, baseUrl]) - if (loading) { - return + useEffect(() => { + offlineInterface.ready.then(() => { + setOfflineInterfaceLoading(false) + }) + }, [offlineInterface]) + + // This needs to come before 'loading' case to show modal at correct times + if (systemInfoState.error || baseUrlState.error) { + return } - if (error) { - return + if ( + systemInfoState.loading || + baseUrlState.loading || + offlineInterfaceLoading + ) { + return } const serverVersion = parseDHIS2ServerVersion(systemInfo.version) @@ -59,7 +149,7 @@ export const ServerVersionProvider = ({ config={{ appName, appVersion: parseVersion(appVersion), - baseUrl: url, + baseUrl, apiVersion: apiVersion || realApiVersion, serverVersion, systemInfo, diff --git a/cli/src/lib/pwa/compileServiceWorker.js b/cli/src/lib/pwa/compileServiceWorker.js index 148c173e4..0a2be598f 100644 --- a/cli/src/lib/pwa/compileServiceWorker.js +++ b/cli/src/lib/pwa/compileServiceWorker.js @@ -1,6 +1,7 @@ const path = require('path') const { reporter } = require('@dhis2/cli-helpers-engine') const webpack = require('webpack') +const getEnv = require('../shell/env') const getPWAEnvVars = require('./getPWAEnvVars') /** @@ -34,13 +35,7 @@ function compileServiceWorker({ config, paths, mode }) { // TODO: This could be cleaner if the production SW is built in the same // way instead of using the CRA webpack config, so both can more easily // share environment variables. - const prefixedPWAEnvVars = Object.entries(getPWAEnvVars(config)).reduce( - (output, [key, value]) => ({ - ...output, - [`REACT_APP_DHIS2_APP_${key.toUpperCase()}`]: value, - }), - {} - ) + const env = getEnv({ name: config.title, ...getPWAEnvVars(config) }) const webpackConfig = { mode, // "production" or "development" @@ -54,7 +49,7 @@ function compileServiceWorker({ config, paths, mode }) { new webpack.DefinePlugin({ 'process.env': JSON.stringify({ ...process.env, - ...prefixedPWAEnvVars, + ...env, }), }), ], diff --git a/examples/pwa-app/d2.config.js b/examples/pwa-app/d2.config.js index a088cc478..00c40e76a 100644 --- a/examples/pwa-app/d2.config.js +++ b/examples/pwa-app/d2.config.js @@ -11,7 +11,8 @@ const config = { entryPoints: { app: './src/App.js', - plugin: './src/components/VisualizationsList.js', + // Uncomment this to test plugin builds: + // plugin: './src/components/VisualizationsList.js', }, } diff --git a/examples/pwa-app/src/App.js b/examples/pwa-app/src/App.js index 006f193d6..78c19aaf6 100644 --- a/examples/pwa-app/src/App.js +++ b/examples/pwa-app/src/App.js @@ -1,9 +1,11 @@ import React from 'react' import classes from './App.module.css' +import RequestTester from './components/RequestTester.js' import SectionWrapper from './components/SectionWrapper.js' const MyApp = () => (
+
) diff --git a/examples/pwa-app/src/components/RequestTester.js b/examples/pwa-app/src/components/RequestTester.js new file mode 100644 index 000000000..17bad2c8f --- /dev/null +++ b/examples/pwa-app/src/components/RequestTester.js @@ -0,0 +1,53 @@ +import { useDataEngine, useDhis2ConnectionStatus } from '@dhis2/app-runtime' +import { Box, Button, ButtonStrip, Help } from '@dhis2/ui' +import React from 'react' + +const query = { + me: { + resource: 'me', + params: { + fields: ['id', 'name'], + }, + }, +} + +export default function RequestTester() { + const engine = useDataEngine() + const { isConnected, lastConnected } = useDhis2ConnectionStatus() + + const internalRequest = () => { + console.log('Request tester: internal request') + engine.query(query) + } + const externalRequest = () => { + console.log('Request tester: external request') + fetch('https://random.dog/woof.json') + } + + return ( +
+
+ Connection to DHIS2 server:{' '} + {isConnected ? ( + Connected + ) : ( + NOT CONNECTED + )} +
+
+ Last connected: {lastConnected?.toLocaleTimeString() || 'null'} +
+ Based on useDhis2ConnectionStatus() + + + + + + +
+ ) +} diff --git a/pwa/src/index.js b/pwa/src/index.js index 67aa47475..51fa2e435 100644 --- a/pwa/src/index.js +++ b/pwa/src/index.js @@ -1,4 +1,4 @@ -export { setUpServiceWorker } from './service-worker/service-worker.js' +export { setUpServiceWorker } from './service-worker/set-up-service-worker.js' export { OfflineInterface } from './offline-interface/offline-interface.js' export { checkForUpdates, @@ -9,3 +9,4 @@ export { REGISTRATION_STATE_ACTIVE, REGISTRATION_STATE_FIRST_ACTIVATION, } from './lib/registration.js' +export { getBaseUrlByAppName, setBaseUrlByAppName } from './lib/base-url-db.js' diff --git a/pwa/src/lib/base-url-db.js b/pwa/src/lib/base-url-db.js new file mode 100644 index 000000000..6903658c9 --- /dev/null +++ b/pwa/src/lib/base-url-db.js @@ -0,0 +1,48 @@ +import { openDB /* deleteDB */ } from 'idb' + +export const BASE_URL_DB = 'dhis2-base-url-db' +export const BASE_URL_STORE = 'dhis2-base-url-store' + +const DB_VERSION = 1 + +/** + * Opens indexed DB and object store for baser urls by app name. Should be used any + * time the DB is accessed to make sure object stores are set up correctly and + * avoid DB-access race condition on first installation. + * + * @returns {Promise} dbPromise. Usage: `const db = await dbPromise` + */ +function openBaseUrlsDB() { + return openDB(BASE_URL_DB, DB_VERSION, { + upgrade(db, oldVersion /* newVersion, transaction */) { + // DB versioning trick that can iteratively apply upgrades + // https://developers.google.com/web/ilt/pwa/working-with-indexeddb#using_database_versioning + switch (oldVersion) { + case 0: { + db.createObjectStore(BASE_URL_STORE, { + keyPath: 'appName', + }) + } + // falls through (this comment satisfies eslint) + default: { + console.debug('[sections-db] Done upgrading DB') + } + } + }, + }) +} + +/** Deletes the DB (probably not needed) */ +// function deleteBaseUrlsDB() { +// return deleteDB(BASE_URL_DB) +// } + +export async function setBaseUrlByAppName({ appName, baseUrl }) { + const db = await openBaseUrlsDB() + return db.put(BASE_URL_STORE, { appName, baseUrl }) +} + +export async function getBaseUrlByAppName(appName) { + const db = await openBaseUrlsDB() + return db.get(BASE_URL_STORE, appName).then((entry) => entry?.baseUrl) +} diff --git a/pwa/src/lib/constants.js b/pwa/src/lib/constants.js index 730c4dee4..fdfce73ab 100644 --- a/pwa/src/lib/constants.js +++ b/pwa/src/lib/constants.js @@ -9,4 +9,7 @@ export const swMsgs = Object.freeze({ confirmRecordingCompletion: 'CONFIRM_RECORDING_COMPLETION', completeRecording: 'COMPLETE_RECORDING', recordingCompleted: 'RECORDING_COMPLETED', + dhis2ConnectionStatusUpdate: 'DHIS2_CONNECTION_STATUS_UPDATE', + getImmediateDhis2ConnectionStatusUpdate: + 'GET_IMMEDIATE_DHIS2_CONNECTION_STATUS_UPDATE', }) diff --git a/pwa/src/offline-interface/offline-interface.js b/pwa/src/offline-interface/offline-interface.js index 5cf0d2beb..3a3483dd3 100644 --- a/pwa/src/offline-interface/offline-interface.js +++ b/pwa/src/offline-interface/offline-interface.js @@ -68,6 +68,10 @@ export class OfflineInterface { // Helper property that consumers can check this.pwaEnabled = PWA_ENABLED + // The latest value from the service worker. The `this.ready` promise + // will resolve when this gets a boolean value from the SW + this.latestIsConnected = null + if (this.pwaEnabled) { register() } else { @@ -101,6 +105,30 @@ export class OfflineInterface { this.offlineEvents.emit(type, payload) } navigator.serviceWorker.addEventListener('message', handleSWMessage) + + // When this promise resolves, it indicates that a connection status + // value has been received from the service worker and is available + // as a property on this offlineInterface. + // Expected to be used by ServerVersionProvider in the app adapter + // to delay rendering the app-runtime Provider until ready. + this.ready = new Promise((resolve) => { + // Listen to status updates and store the latest value here so the + // connection status hook can initialize to this value + this.offlineEvents.on( + swMsgs.dhis2ConnectionStatusUpdate, + ({ isConnected }) => { + // If this is the first time receiving an update from the + // SW, resolve the this.ready promise + const shouldResolveReady = this.latestIsConnected === null + this.latestIsConnected = isConnected + if (shouldResolveReady) { + resolve() + } + } + ) + }) + // Prompt the SW to send back connection status without its usual delay + swMessage(swMsgs.getImmediateDhis2ConnectionStatusUpdate) } /** Basically `checkForUpdates` from registration.js exposed here */ @@ -172,6 +200,17 @@ export class OfflineInterface { }) } + /** + * @param {Object} params + * @param {Function} params.onUpdate - Called on status updates with argument { isConnected: bool } + * @returns {Function} - An unsubscribe function + */ + subscribeToDhis2ConnectionStatus({ onUpdate }) { + this.offlineEvents.on(swMsgs.dhis2ConnectionStatusUpdate, onUpdate) + return () => + this.offlineEvents.off(swMsgs.dhis2ConnectionStatusUpdate, onUpdate) + } + /** * Starts a recording session for a cacheable section. Returns a promise * that resolves if the SW message is successfully sent or rejects if diff --git a/pwa/src/service-worker/dhis2-connection-status.js b/pwa/src/service-worker/dhis2-connection-status.js new file mode 100644 index 000000000..c2fb05d50 --- /dev/null +++ b/pwa/src/service-worker/dhis2-connection-status.js @@ -0,0 +1,73 @@ +import throttle from 'lodash/throttle' +import { getBaseUrlByAppName } from '../lib/base-url-db.js' +import { swMsgs } from '../lib/constants.js' +import { getAllClientsInScope } from './utils.js' + +/** + * Tracks connection to the DHIS2 server based on fetch successes or failures. + * Starts as null because it can't be determined until a request is sent + */ +export function initDhis2ConnectionStatus() { + // base url is only set as an env var in production. + // in dev/standalone env, this may be undefined, + // and the base URL can be accessed from IDB later. + // note: if this SW is part of a global shell, + // URL would need to be found on a per-client basis + const dhis2BaseUrl = process.env.REACT_APP_DHIS2_BASE_URL + if (dhis2BaseUrl) { + try { + self.dhis2BaseUrl = new URL(dhis2BaseUrl).href + } catch { + // the base URL is relative; construct an absolute one + self.dhis2BaseUrl = new URL(dhis2BaseUrl, self.location.href).href + } + } +} + +// Throttle this a bit to reduce SW/client messaging +const BROADCAST_INTERVAL_MS = 1000 +export const broadcastDhis2ConnectionStatus = throttle(async (isConnected) => { + const clients = await getAllClientsInScope() + clients.forEach((client) => + client.postMessage({ + type: swMsgs.dhis2ConnectionStatusUpdate, + payload: { isConnected }, + }) + ) +}, BROADCAST_INTERVAL_MS) + +async function isRequestToDhis2Server(request) { + // If dhis2BaseUrl isn't set, try getting it from IDB + if (!self.dhis2BaseUrl) { + const baseUrl = await getBaseUrlByAppName( + process.env.REACT_APP_DHIS2_APP_NAME + ) + if (!baseUrl) { + // No base URL is set; as a best effort, go ahead and update status + // based on this request, even though it might not be to the DHIS2 server + return true + } else { + self.dhis2BaseUrl = baseUrl + } + } + + return request.url.startsWith(self.dhis2BaseUrl) +} + +/** + * A plugin to hook into lifecycle events in workbox strategies + * https://developer.chrome.com/docs/workbox/using-plugins/ + */ +export const dhis2ConnectionStatusPlugin = { + fetchDidFail: async ({ request }) => { + if (await isRequestToDhis2Server(request)) { + broadcastDhis2ConnectionStatus(false) + } + }, + fetchDidSucceed: async ({ request, response }) => { + if (await isRequestToDhis2Server(request)) { + broadcastDhis2ConnectionStatus(true) + } + return response + }, +} diff --git a/pwa/src/service-worker/recording-mode.js b/pwa/src/service-worker/recording-mode.js index 5f6152dd4..9633677ec 100644 --- a/pwa/src/service-worker/recording-mode.js +++ b/pwa/src/service-worker/recording-mode.js @@ -1,3 +1,4 @@ +import { Strategy } from 'workbox-strategies' import { swMsgs } from '../lib/constants.js' import { openSectionsDB, SECTIONS_STORE } from '../lib/sections-db.js' @@ -8,6 +9,14 @@ const CACHEABLE_SECTION_URL_FILTER_PATTERNS = JSON.parse( '[]' ).map((pattern) => new RegExp(pattern)) +/** + * Tracks recording states for multiple clients to handle multiple windows + * recording simultaneously + */ +export function initClientRecordingStates() { + self.clientRecordingStates = {} +} + // Triggered on 'START_RECORDING' message export function startRecording(event) { console.debug('[SW] Starting recording') @@ -77,19 +86,25 @@ export function shouldRequestBeRecorded({ url, event }) { } /** Request handler during recording mode */ -export function handleRecordedRequest({ request, event }) { - const recordingState = self.clientRecordingStates[event.clientId] +export class RecordingMode extends Strategy { + _handle(request, handler) { + const { event } = handler + const recordingState = self.clientRecordingStates[event.clientId] - clearTimeout(recordingState.recordingTimeout) - recordingState.pendingRequests.add(request) + clearTimeout(recordingState.recordingTimeout) + recordingState.pendingRequests.add(request) - fetch(request) - .then((response) => { - return handleRecordedResponse(request, response, event.clientId) - }) - .catch((error) => { - stopRecording(error, event.clientId) - }) + return handler + .fetch(request) + .then((response) => { + return handleRecordedResponse(request, response, event.clientId) + }) + .catch((error) => { + stopRecording(error, event.clientId) + // trigger 'fetchDidFail' callback + throw error + }) + } } /** Response handler during recording mode */ diff --git a/pwa/src/service-worker/service-worker.js b/pwa/src/service-worker/set-up-service-worker.js similarity index 84% rename from pwa/src/service-worker/service-worker.js rename to pwa/src/service-worker/set-up-service-worker.js index 2252bda09..8a053d18f 100644 --- a/pwa/src/service-worker/service-worker.js +++ b/pwa/src/service-worker/set-up-service-worker.js @@ -2,15 +2,22 @@ import { precacheAndRoute, matchPrecache, precache } from 'workbox-precaching' import { registerRoute, setDefaultHandler } from 'workbox-routing' import { NetworkFirst, + NetworkOnly, StaleWhileRevalidate, Strategy, } from 'workbox-strategies' import { swMsgs } from '../lib/constants.js' +import { + broadcastDhis2ConnectionStatus, + dhis2ConnectionStatusPlugin, + initDhis2ConnectionStatus, +} from './dhis2-connection-status' import { startRecording, completeRecording, - handleRecordedRequest, shouldRequestBeRecorded, + initClientRecordingStates, + RecordingMode, } from './recording-mode.js' import { urlMeetsAppShellCachingCriteria, @@ -38,9 +45,8 @@ export function setUpServiceWorker() { // Globals (Note: global state resets each time SW goes idle) - // Tracks recording states for multiple clients to handle multiple windows - // recording simultaneously - self.clientRecordingStates = {} + initClientRecordingStates() + initDhis2ConnectionStatus() // Local constants @@ -138,9 +144,20 @@ export function setUpServiceWorker() { precacheAndRoute(sharedBuildManifest) } + // Handling pings: only use the network, and don't update the connection + // status (let the runtime do that) + // Two endpoints: /api(/version)/system/ping and /api/ping + registerRoute( + ({ url }) => /\/api(\/\d+)?(\/system)?\/ping/.test(url.pathname), + new NetworkOnly() + ) + // Request handler during recording mode: ALL requests are cached // Handling routing: https://developers.google.com/web/tools/workbox/modules/workbox-routing#matching_and_handling_in_routes - registerRoute(shouldRequestBeRecorded, handleRecordedRequest) + registerRoute( + shouldRequestBeRecorded, + new RecordingMode({ plugins: [dhis2ConnectionStatusPlugin] }) + ) // If not recording, fall through to default caching strategies for app // shell: @@ -151,7 +168,10 @@ export function setUpServiceWorker() { PRODUCTION_ENV && urlMeetsAppShellCachingCriteria(url) && /\.(jpg|gif|png|bmp|tiff|ico|woff)$/.test(url.pathname), - new StaleWhileRevalidate({ cacheName: 'other-assets' }) + new StaleWhileRevalidate({ + cacheName: 'other-assets', + plugins: [dhis2ConnectionStatusPlugin], + }) ) // Network-first caching by default @@ -161,7 +181,10 @@ export function setUpServiceWorker() { ({ url }) => urlMeetsAppShellCachingCriteria(url) || (!PRODUCTION_ENV && fileExtensionRegexp.test(url.pathname)), - new NetworkFirst({ cacheName: 'app-shell' }) + new NetworkFirst({ + cacheName: 'app-shell', + plugins: [dhis2ConnectionStatusPlugin], + }) ) // Strategy for all other requests: try cache if network fails, @@ -182,7 +205,9 @@ export function setUpServiceWorker() { } } // Use fallback strategy as default - setDefaultHandler(new NetworkAndTryCache()) + setDefaultHandler( + new NetworkAndTryCache({ plugins: [dhis2ConnectionStatusPlugin] }) + ) // Service Worker event handlers @@ -206,6 +231,15 @@ export function setUpServiceWorker() { self.skipWaiting() } + // Immediately trigger this throttled function -- this allows the app + // to get the value ASAP upon startup, which it otherwise usually + // has to wait for + if ( + event.data.type === swMsgs.getImmediateDhis2ConnectionStatusUpdate + ) { + broadcastDhis2ConnectionStatus.flush() + } + if (event.data.type === swMsgs.startRecording) { startRecording(event) } diff --git a/pwa/src/service-worker/utils.js b/pwa/src/service-worker/utils.js index d8c69657e..5f6f499ae 100644 --- a/pwa/src/service-worker/utils.js +++ b/pwa/src/service-worker/utils.js @@ -107,18 +107,11 @@ export async function removeUnusedCaches() { ) } -/** - * Can be used to access information about this service worker's clients. - * Sends back information on a message with 'CLIENTS_INFO' type; the payload - * currently contains the number of current clients, including uncontrolled. - * @returns {Object} { clientsCounts: number } - */ -export async function getClientsInfo(event) { - const clientId = event.source.id - +/** Get all clients including uncontrolled, but only those within SW scope */ +export function getAllClientsInScope() { // Include uncontrolled clients: necessary to know if there are multiple // tabs open upon first SW installation - const filteredClientsList = await self.clients + return self.clients .matchAll({ includeUncontrolled: true, }) @@ -129,12 +122,24 @@ export async function getClientsInfo(event) { client.url.startsWith(self.registration.scope) ) ) +} + +/** + * Can be used to access information about this service worker's clients. + * Sends back information on a message with 'CLIENTS_INFO' type; the payload + * currently contains the number of current clients, including uncontrolled. + * @returns {Object} { clientsCounts: number } + */ +export async function getClientsInfo(event) { + const clientId = event.source.id + + const clientsList = await getAllClientsInScope() self.clients.get(clientId).then((client) => { client.postMessage({ type: swMsgs.clientsInfo, payload: { - clientsCount: filteredClientsList.length, + clientsCount: clientsList.length, }, }) }) diff --git a/shell/package.json b/shell/package.json index cdd899c05..3a9471883 100644 --- a/shell/package.json +++ b/shell/package.json @@ -16,7 +16,7 @@ }, "dependencies": { "@dhis2/app-adapter": "10.2.3", - "@dhis2/app-runtime": "^3.6.1", + "@dhis2/app-runtime": "^3.9.0", "@dhis2/d2-i18n": "^1.1.1", "@dhis2/pwa": "10.2.3", "@dhis2/ui": "^8.6.2", diff --git a/shell/src/App.js b/shell/src/App.js index a606488e2..1e5115c9c 100644 --- a/shell/src/App.js +++ b/shell/src/App.js @@ -7,9 +7,7 @@ const D2App = React.lazy(() => ) // Automatic bundle splitting! const appConfig = { - url: - process.env.REACT_APP_DHIS2_BASE_URL || - window.localStorage.DHIS2_BASE_URL, + url: process.env.REACT_APP_DHIS2_BASE_URL, appName: process.env.REACT_APP_DHIS2_APP_NAME || '', appVersion: process.env.REACT_APP_DHIS2_APP_VERSION || '', apiVersion: parseInt(process.env.REACT_APP_DHIS2_API_VERSION), diff --git a/yarn.lock b/yarn.lock index 7e5763d52..7185a7e6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2110,37 +2110,37 @@ classnames "^2.3.1" prop-types "^15.7.2" -"@dhis2/app-runtime@^3.6.1": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-3.6.1.tgz#d39c492239cc81faf2b3ec92fe39c11440640953" - integrity sha512-I+hTHXTqqDSbOCRFd/60AvCGkyYmwMtUD1EkyURuB/NaK37xQQZzrz2/JD3esBYGl2Ner3nLoOgbQwXEMvBeZw== - dependencies: - "@dhis2/app-service-alerts" "3.6.1" - "@dhis2/app-service-config" "3.6.1" - "@dhis2/app-service-data" "3.6.1" - "@dhis2/app-service-offline" "3.6.1" - -"@dhis2/app-service-alerts@3.6.1": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-alerts/-/app-service-alerts-3.6.1.tgz#e3081d05a70b12f8da171e44afc45bdd04265be5" - integrity sha512-hv7cSvSEwlxsSzRoqQ83ymzaMl6lXcFJ3gpsG1qDebNVAjRZAWXZV9GKWGXtdP7GRCOoOypXrzv8WgUlHgMhRQ== - -"@dhis2/app-service-config@3.6.1": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-config/-/app-service-config-3.6.1.tgz#0fb2132e8515e9bdf0af83eb7a138583ff885fe4" - integrity sha512-n3Awr5I1qhJ8UHQTmdaBRiwStU8XbSRVEDfE7TnA0kFCVWoDZIH4YJxnmcTFJLUFxYtaEN4nN9GgrGBGoVcfnQ== - -"@dhis2/app-service-data@3.6.1": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-data/-/app-service-data-3.6.1.tgz#74b0d4d2b4935aa416d52863eacea9c15a1c4d80" - integrity sha512-BX1VOvkwaGmi9NB+r4nnjUQ34tXMlK44c+Tc2jI7EYP6jCSRsbGXeBagP5nSkBdm7XXb2IWlvY2n3/fZFed0kg== +"@dhis2/app-runtime@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-3.9.0.tgz#c7e295fd0a68fac976a930bc77105206ded0b61a" + integrity sha512-n0S4pbyvK7FnBQFMONGrhR9YYavBQI+mQLHfCX/vtvOyeoioBUNIinuQlGysuLMEkSVaK5OjV40rvTMzdxF2kQ== + dependencies: + "@dhis2/app-service-alerts" "3.9.0" + "@dhis2/app-service-config" "3.9.0" + "@dhis2/app-service-data" "3.9.0" + "@dhis2/app-service-offline" "3.9.0" + +"@dhis2/app-service-alerts@3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-alerts/-/app-service-alerts-3.9.0.tgz#48d3805676e75ee58104fea4f76cfa779335444e" + integrity sha512-z2eZxm/pxrmFbisbK7/qJKtif2CNWJjaaAH5rfrs5OIajlHy3rO37vSaTQHWv+xWvZFQrs2Op2InxzG0qh5ncA== + +"@dhis2/app-service-config@3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-config/-/app-service-config-3.9.0.tgz#8dc59d8de246f54057c0c685d5f94b4cbade6f73" + integrity sha512-OuRn2mJGrQQ8QIC+oIVYYpclB4LErRK2wtsuy/cXLfRbeUti1qWIh110rgd1hnTx+BgRCs5s3NWdIQxS4hYGIQ== + +"@dhis2/app-service-data@3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-data/-/app-service-data-3.9.0.tgz#37f528b5f7f589cbab8dcc7f997c1668bc6566a9" + integrity sha512-/FJgJhL6YGtIVNX5oaNmavkGmimrVHQsS8ueeUO4FvTjYXGlnnN3IuxypQcy/x4yiUyigbPgFJRnbC1J2af2fg== dependencies: react-query "^3.13.11" -"@dhis2/app-service-offline@3.6.1": - version "3.6.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-offline/-/app-service-offline-3.6.1.tgz#4c010888d5b7255920304b8da1581af2144540da" - integrity sha512-nj2FwFiU/XbMsbr+I4HG2v/tmXJW2VBESyhqZ57nzBKhFfVBJgB1bdLBq3gCvw1tRZC2UhqSplilx/vFXg6c8g== +"@dhis2/app-service-offline@3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-offline/-/app-service-offline-3.9.0.tgz#fe4f4a91a1da77554965f6a5fe6f6951d4c467f4" + integrity sha512-0q5zl0vw+a47Ab2qgu6hsZY5ybnH/ea43Vkk4aXYdgcf57xB8ck9DkIcNbc2e1+k9FhvimipxsgTZSbEA/8hJA== dependencies: lodash "^4.17.21"