import type { ModulesInstance } from '@equinor/fusion-framework-module'; import type { MsalModule } from '@equinor/fusion-framework-module-msal'; import { TelemetryLevel, type TelemetryModule } from '@equinor/fusion-framework-module-telemetry'; /** * Registers the Fusion SPA service worker and wires token acquisition. * * @remarks * The service worker intercepts outgoing fetch requests that match * the configured {@link ResourceConfiguration | resource patterns}, * rewrites URLs, and injects Bearer tokens obtained from the MSAL * module. This function: * * 1. Registers `/@fusion-spa-sw.js` as a module service worker. * 2. Listens for `GET_TOKEN` messages from the worker and responds * with MSAL access tokens. * 3. Sends the `INIT_CONFIG` message containing resource configurations * to the active worker once it is ready and controlling the page. * * @param framework - An initialized Fusion Framework instance that * includes the {@link MsalModule} (for token acquisition) and * {@link TelemetryModule} (for structured logging). * @throws {Error} When service workers are not supported by the browser. * @throws {Error} When the `FUSION_SPA_SERVICE_WORKER_RESOURCES` * environment variable is not defined. * * @example * ```ts * import { registerServiceWorker } from '@equinor/fusion-framework-vite-plugin-spa/html'; * * const framework = await configurator.initialize(); * await registerServiceWorker(framework); * ``` */ export async function registerServiceWorker( framework: ModulesInstance<[MsalModule, TelemetryModule]>, ) { const telemetry = framework.telemetry; // Bail out early when the browser has no service worker support at all if ('serviceWorker' in navigator === false) { const exception = new Error('Service workers are not supported in this browser.'); exception.name = 'ServiceWorkerNotSupported'; telemetry.trackException({ name: `registerServiceWorker.${exception.name}`, exception, }); throw exception; } const resourceConfigs = import.meta.env.FUSION_SPA_SERVICE_WORKER_RESOURCES; // The worker needs a resource config to know which requests to intercept if (!resourceConfigs) { const exception = new Error('Service worker config is not defined.'); exception.name = 'ServiceWorkerConfigNotDefined'; telemetry.trackException({ name: `registerServiceWorker.${exception.name}`, exception, }); throw exception; } /** * Helper function to send configuration to the service worker */ const sendConfigToServiceWorker = (worker: ServiceWorker) => { worker.postMessage({ type: 'INIT_CONFIG', config: resourceConfigs, }); }; try { // allow the service worker to start receiving messages early navigator.serviceWorker.startMessages(); // listen for messages from the service worker (set up before registration) navigator.serviceWorker.addEventListener('message', async (event) => { // Only the GET_TOKEN message type requests a token from this handler if (event.data.type === 'GET_TOKEN') { try { // extract scopes from the event data const scopes = event.data.scopes as string[]; // Scopes must be a real array before they can be used to request a token if (!scopes || !Array.isArray(scopes)) { const error = new Error('Invalid scopes provided'); error.name = 'InvalidScopesProvided'; throw error; } // request a token from the MSAL module const token = await framework.auth.acquireToken({ request: { scopes } }); // A missing token means acquisition failed and the worker can't proceed if (!token) { const error = new Error('Failed to acquire token'); error.name = 'FailedToAcquireToken'; throw error; } // send the token back to the service worker event.ports[0].postMessage({ accessToken: token.accessToken, expiresOn: token.expiresOn?.getTime(), }); } catch (error) { const exception = error as Error; telemetry.trackException({ name: `serviceWorker.onMessage.${exception.name}`, exception, }); event.ports[0].postMessage({ error: (error as Error).message, }); } } }); // register the service worker with telemetry // updateViaCache: 'none' ensures the service worker script is always fetched fresh // This is important during development to pick up code changes using measurement = telemetry.measure({ name: 'registerServiceWorker', level: TelemetryLevel.Information, }); const registration = await measurement.clone().resolve( navigator.serviceWorker.register('/@fusion-spa-sw.js', { type: 'module', scope: '/', updateViaCache: 'none', }), { data: { name: 'registerServiceWorker.register', level: TelemetryLevel.Debug, }, }, ); // Handle service worker updates/installations // If there's a service worker waiting or installing, send config when it activates if (registration.waiting) { sendConfigToServiceWorker(registration.waiting); } // A worker still installing needs to reach the 'activated' state before it can receive config if (registration.installing) { registration.installing.addEventListener('statechange', (event) => { const worker = event.target as ServiceWorker; // Only send config once the worker has fully activated if (worker.state === 'activated') { sendConfigToServiceWorker(worker); } }); } // Listen for controller changes (happens during hard refresh or updates) navigator.serviceWorker.addEventListener('controllerchange', () => { // Only send config once this page is actually controlled by a worker if (navigator.serviceWorker.controller) { sendConfigToServiceWorker(navigator.serviceWorker.controller); } }); // wait for the service worker to be ready const readyRegistration = await measurement.clone().resolve(navigator.serviceWorker.ready, { data: { name: 'registerServiceWorker.ready', level: TelemetryLevel.Debug, }, }); // ensure we have an active service worker before sending config const activeWorker = readyRegistration.active; // Without an active worker there's nothing to send config to if (!activeWorker) { console.error('[Service Worker Registration] Service worker is not active after ready state'); return; } // CRITICAL: Wait for the service worker to become the controller // This ensures the service worker can intercept fetch requests if (!navigator.serviceWorker.controller) { await measurement.clone().resolve( new Promise((resolve) => { let checkInterval: NodeJS.Timeout; const finish = () => { clearInterval(checkInterval); navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); resolve(); }; const onControllerChange = () => finish(); // If controllerchange fires, the service worker has taken control navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); // Polling fallback and timeout to prevent infinite waiting checkInterval = setInterval(() => { // Stop polling once a controller has taken over if (navigator.serviceWorker.controller) finish(); }, 200); setTimeout(finish, 5000); }), { data: { name: 'registerServiceWorker.controllerWait', level: TelemetryLevel.Debug, }, }, ); } // send the config to the active service worker sendConfigToServiceWorker(activeWorker); // Also send to the controller if it exists and is different from active if (navigator.serviceWorker.controller && navigator.serviceWorker.controller !== activeWorker) { sendConfigToServiceWorker(navigator.serviceWorker.controller); } } catch (error) { telemetry.trackException({ name: `registerServiceWorker.${(error as Error).name}`, exception: error as Error, }); } }