/// import type { ResourceConfiguration } from '../types.js'; /** * Represents an authentication token with an access token and its expiration time. */ type Token = { accessToken: string; expiresOn: number; }; /** * A cache structure for storing tokens, where each token is associated with a unique string key. * * @typeParam string - The key used to identify a token in the cache. * @typeParam Token - The type of the token being stored in the cache. */ type TokenCache = Map; /** * A reference to the global scope of the service worker. * * The `self` variable is explicitly cast to `ServiceWorkerGlobalScope` to ensure * type safety and provide access to service worker-specific APIs. * * This is necessary because `globalThis` is a generic global object and does not * include service worker-specific properties and methods by default. */ const self = globalThis as unknown as ServiceWorkerGlobalScope; /** * An array of settings used for token injection. * Each setting defines the configuration for injecting tokens * into the application, such as authentication or API tokens. */ let resourceConfigurations: ResourceConfiguration[] = []; /** * A cache for storing tokens, implemented as a `Map`. * This cache is used to temporarily hold tokens for quick retrieval. * * @type {TokenCache} - A `Map` instance where the keys and values are determined by the `TokenCache` type definition. */ const tokenCache: TokenCache = new Map(); /** * Generates a unique key by sorting and concatenating an array of scope strings. * * @param scopes - An array of strings representing the scopes to be processed. * @returns A single string representing the sorted and concatenated scopes, separated by commas. */ function getScopeKey(scopes: string[]): string { return scopes.sort().join(','); } /** * Checks if a token associated with the specified scopes is valid. * * This function determines the validity of a token by checking if it exists * in the token cache and if its expiration time has not been reached. * * @param scopes - An array of strings representing the scopes for which the token is required. * @returns `true` if a valid token exists for the given scopes; otherwise, `false`. */ function isTokenValid(scopes: string[]): boolean { const scopeKey = getScopeKey(scopes); // No cached entry at all means there's nothing to validate if (!tokenCache.has(scopeKey)) { return false; } const tokenData = tokenCache.get(scopeKey); return tokenData !== undefined && Date.now() < tokenData.expiresOn; } /** * Requests an access token from a client using the Service Worker's `clients` API. * Communicates with the client via a `MessageChannel` to retrieve the token. * * @param scopes - An array of strings representing the scopes for which the token is requested. * @returns A promise that resolves to the token object containing the `accessToken` and `expiresOn` properties. * @throws An error if no clients are available or if the client responds with an error. * * @example * ```typescript * const token = await requestTokenFromClient(['scope1', 'scope2']); * console.log(token.accessToken); // Access token string * console.log(token.expiresOn); // Expiration timestamp * ``` */ async function requestTokenFromClient(scopes: string[]): Promise { const clients = await self.clients.matchAll(); // ensure there are clients available if (clients.length === 0) { throw new Error('No clients available'); } // create a message channel to communicate with the client const messageChannel = new MessageChannel(); const token = await new Promise((resolve, reject) => { messageChannel.port1.onmessage = (event) => { // Reject when the client reports it couldn't provide a token if (event.data.error) { reject(event.data.error); } resolve(event.data as { accessToken: string; expiresOn: number }); }; clients[0].postMessage({ type: 'GET_TOKEN', scopes }, [messageChannel.port2]); }); // A resolved but empty response means the client didn't actually return a token if (!token) { throw new Error('No token received'); } // store the token in the cache tokenCache.set(getScopeKey(scopes), token); return token; } /** * Retrieves an access token for the specified scopes. If no valid token is found, * it requests a new one from the client. * * @param scopes - An array of strings representing the required scopes for the token. * @returns A promise that resolves to the access token as a string. * @throws An error if no access token is found after attempting to retrieve or request one. */ async function getToken(scopes: string[]): Promise { // if no valid token is found, request a new one if (!isTokenValid(scopes)) { await requestTokenFromClient(scopes); } const scopeKey = getScopeKey(scopes); const { accessToken } = tokenCache.get(scopeKey) || {}; // A missing accessToken here means the client failed to provide a usable token if (!accessToken) { throw new Error('No access token found'); } return accessToken; } // Match request to proxy config /** * Retrieves the matching token injection configuration for a given URL. * * @param url - The URL to match against the token injection settings. * @returns The matching `TokenInjectionSetting` if found, otherwise `undefined`. * * The function compares the provided URL with the `url` property of each * `TokenInjectionSetting` in the `tokenInjectionSettings` array. If the * provided URL starts with the resolved `config.url`, it is considered a match. * * Note: * - If `config.url` starts with a `/`, it is resolved relative to the service * worker's origin (`self.location.origin`). * - The comparison is performed using fully resolved absolute URLs. */ function getMatchingConfig(url: string): ResourceConfiguration | undefined { // Find the first configured resource whose resolved base URL prefixes the request URL return resourceConfigurations.find((config) => { const configUrl = new URL( config.url, config.url.startsWith('/') ? self.location.origin : undefined, ).href; const requestUrl = new URL(url, self.location.origin).href; return requestUrl.startsWith(configUrl); }); } // Install event self.addEventListener('install', (event: ExtendableEvent) => { event.waitUntil(self.skipWaiting()); }); // Activate event self.addEventListener('activate', (event: ExtendableEvent) => { event.waitUntil(self.clients.claim()); }); // Handle configuration from main thread self.addEventListener('message', async (event: ExtendableMessageEvent) => { const { type, config } = event.data; // Only the INIT_CONFIG message carries resource configuration to apply if (type === 'INIT_CONFIG') { resourceConfigurations = config as ResourceConfiguration[]; // CRITICAL: Force skipWaiting() and claim clients to ensure this service worker takes control // This handles both waiting and already-active service workers during hard refresh // - skipWaiting() forces activation if the service worker is in waiting state // - clients.claim() takes control of all clients immediately await self.skipWaiting(); await self.clients.claim(); } }); // Handle fetch events self.addEventListener('fetch', (event: FetchEvent) => { const request = event.request.clone(); const url = new URL(request.url); const matchedConfig = getMatchingConfig(url.toString()); // only handle requests that match the config if (matchedConfig) { const requestHeaders = new Headers(request.headers); const handleRequest = async () => { // if the matched config has scopes, append the token to the request if (matchedConfig.scopes) { const token = await getToken(matchedConfig.scopes); requestHeaders.set('Authorization', `Bearer ${token}`); } // if the matched config has a rewrite, rewrite the url if (typeof matchedConfig.rewrite === 'string') { url.pathname = url.pathname.replace(matchedConfig?.url, matchedConfig.rewrite); } // Consume the ReadableStream body and convert to text // ReadableStreams can only be consumed once, so we extract the content here // request.text() resolves to empty string when the request has no body const body = await request.text(); // fetch the request with the modified url and headers, preserving the original HTTP method and body // This ensures OPTIONS, PATCH, DELETE and other methods are forwarded correctly // `cache` is forwarded explicitly - otherwise callers requesting `no-store` (e.g. polling // endpoints) would silently fall back to the default HTTP cache once re-fetched here. return fetch(url, { method: request.method, headers: requestHeaders, body: body || undefined, cache: request.cache, }); }; event.respondWith(handleRequest()); } });