import type { Context, ExtensionAPI, ExtensionAttributes, ExtensionAttributesProvider, ExtensionCleanupCallback, ExtensionDescriptor, MaybeOnAction, OnAction, } from '@atlassian/clientside-extensions-registry'; import registry from '@atlassian/clientside-extensions-registry'; import { onDebug } from '@atlassian/clientside-extensions-debug'; import type { Observable, Observer } from '@atlassian/clientside-extensions-base'; import { ReplaySubject } from '@atlassian/clientside-extensions-base'; // We only import types here // eslint-disable-next-line node/no-unpublished-import import type { Validator, ValidationError, ValidationWarning } from '@atlassian/clientside-extensions-schema'; import type { ExtensionPointUpdate, ExtensionState, Options } from './types'; import { isNotNullOrUndefined } from './types'; const safeguardAction = (action: OnAction, descriptorKey: string, location: string): OnAction => { return (...args: unknown[]) => { try { return action(...args); } catch (e) { onDebug(({ error }) => ({ level: error, message: `Failed to execute onAction callback for extension "${descriptorKey}" at extension: ${location}. Error: ${e}`, meta: { extension: descriptorKey, location, }, })); return undefined; } }; }; const actionsCache = new Map(); const cachedSafeguardAction = (maybeAction: MaybeOnAction, descriptorKey: string, location: string): OnAction | undefined => { if (!maybeAction) { return undefined; } const action = maybeAction as OnAction; if (actionsCache.has(action)) { return actionsCache.get(action); } const guardedAction = safeguardAction(action, descriptorKey, location); actionsCache.set(action, guardedAction); return guardedAction; }; const mergeAttributes = ( base: ExtensionAttributes, provided: ExtensionAttributes, onAction?: ExtensionAttributes['onAction'], ): ExtensionAttributes => { if (!onAction) { return { ...base, ...provided, }; } return { ...base, ...provided, onAction, }; }; const getExtensionApi = ( key: string, extensionPoint: string, attributeMap: Map, queueUpdate: ReturnType, cleanupCallbackQueue: ReturnType['queue'], ) => { const extensionAPI: ExtensionAPI = { updateAttributes(fnOrAttributes) { const currentAttributes = (attributeMap.has(key) && attributeMap.get(key)) || {}; try { const updatedAttributes = typeof fnOrAttributes === 'function' ? fnOrAttributes(currentAttributes) : fnOrAttributes; const attributes = { ...currentAttributes, ...updatedAttributes }; attributeMap.set(key, attributes); queueUpdate(key); } catch (e) { onDebug(({ error }) => ({ level: error, message: `Updating attributes for extension ${key} failed: ${e}`, meta: { extension: key, location: extensionPoint, }, })); } }, onCleanup(cleanupCallback) { cleanupCallbackQueue.push([key, cleanupCallback]); }, }; return extensionAPI; }; const getExtensionAttributes = ( key: string, extensionPoint: string, attributesProvider: ExtensionAttributesProvider | undefined, baseAttributes: ExtensionDescriptor['attributes'], extensionAPI: ExtensionAPI, context: Context<{}> | null, ): ExtensionAttributes | undefined => { if (!attributesProvider) { return baseAttributes; } // Try to get the attributes from the provider let providedAttributes: ExtensionAttributes = {}; try { const rawProvidedAttributes = attributesProvider(extensionAPI, context); if (!rawProvidedAttributes || Object.getPrototypeOf(rawProvidedAttributes) !== Object.prototype) { throw new TypeError('The attributes provider functions should return attributes object'); } providedAttributes = rawProvidedAttributes; } catch (e) { onDebug(({ error }) => ({ level: error, message: `Calling the attributes provider for extension ${key} failed: ${e}`, meta: { extension: key, location: extensionPoint, }, })); return undefined; } // Cache the onAction if present const onAction = providedAttributes.onAction && cachedSafeguardAction(providedAttributes.onAction, key, extensionPoint); return mergeAttributes(baseAttributes, providedAttributes, onAction); }; const sortDescriptorsByWeight = (extensionDescriptors: ExtensionDescriptor[]) => { return extensionDescriptors.sort((a: ExtensionDescriptor, b: ExtensionDescriptor) => { const aWeight = a?.attributes?.weight ?? Infinity; const bWeight = b?.attributes?.weight ?? Infinity; return aWeight - bWeight; }); }; type DescriptorUpdateBatch = { descriptors: ExtensionDescriptor[]; updatedDescriptors: ExtensionDescriptor[]; }; const inlineUpdateAttributes = ( keys: Set, attributeMap: Map, descriptors: ExtensionDescriptor[], ): DescriptorUpdateBatch => { const updated: ExtensionDescriptor[] = []; descriptors .filter((descriptor) => keys.has(descriptor.key)) .forEach((descriptor) => { descriptor.attributes = { ...descriptor.attributes, ...attributeMap.get(descriptor.key), }; updated.push(descriptor); }); return { descriptors: sortDescriptorsByWeight(descriptors), updatedDescriptors: updated, }; }; const createAnimationFrameQueue = (callback: (params: Set) => void) => { let queue = new Set(); let requestIsQueued = false; const flushUpdate = () => { requestIsQueued = false; callback(queue); queue = new Set(); }; return (key: string) => { queue.add(key); if (!requestIsQueued) { requestIsQueued = true; requestAnimationFrame(flushUpdate); } }; }; const createCleanupCallbackQueue = () => { const queue: [string, ExtensionCleanupCallback][] = []; const cleanUp = () => { while (queue.length) { const result = queue.shift(); if (!result) { break; } const [descriptorKey, callback] = result; try { callback(); } catch (e) { onDebug(({ error }) => ({ level: error, message: `Failed to execute cleanup callback registered for "${descriptorKey}". See passed callback for reference: ${callback}`, meta: { extension: descriptorKey, callback, }, })); } } }; return { queue, cleanUp, }; }; const EMPTY_PAYLOAD: ExtensionPointUpdate = { state: { descriptors: [], loadingState: true }, update: [] }; // eslint-disable-next-line import/prefer-default-export export const getExtensionPointSubscription = | null, AttributesT extends ExtensionAttributes>( extensionPoint: string, context: ContextT, ): Observable> => { let singletonObserver: Observer>; let currentState: ExtensionDescriptor[] = []; let currentLoadingState: boolean = true; const updateSubject = new ReplaySubject>(1); const cleanupCallbackQueue = createCleanupCallbackQueue(); const descriptorAttributeMap = new Map(); const animationFrameQueue = createAnimationFrameQueue((updatedKeys) => { const { descriptors, updatedDescriptors } = inlineUpdateAttributes(updatedKeys, descriptorAttributeMap, currentState); currentState = descriptors; updateSubject.notify({ state: { descriptors: currentState, loadingState: currentLoadingState }, update: updatedDescriptors }); }); const subscription = registry.getLocation(extensionPoint).subscribe(({ descriptors, loadingState }) => { // no need to validate while we are still loading. if (loadingState) { // @ts-expect-error Some issues with generics updateSubject.notify(EMPTY_PAYLOAD); return; } /* * TODO * - do we need to remove descriptors from the `descriptorMap` if they are no longer within the listed descriptors? * - do we need to override descriptors everytime they get "re-registered" or is that a `dev-mode` only feature? */ const extensionDescriptors = descriptors .map((rawDescriptor): ExtensionDescriptor | undefined => { const { attributesProvider, attributes: baseAttributes, key, ...descriptor } = rawDescriptor; const extensionAPI = getExtensionApi( key, extensionPoint, descriptorAttributeMap, animationFrameQueue, cleanupCallbackQueue.queue, ); const maybeAttributes = getExtensionAttributes( key, extensionPoint, attributesProvider, baseAttributes, extensionAPI, context, ); if (maybeAttributes === undefined) { return undefined; } const attributes: ExtensionAttributes = maybeAttributes; descriptorAttributeMap.set(key, attributes); const fullDescriptor: ExtensionDescriptor = { ...descriptor, key, attributes: attributes as AttributesT, }; return fullDescriptor; }) .filter(isNotNullOrUndefined); currentState = sortDescriptorsByWeight(extensionDescriptors); currentLoadingState = loadingState; updateSubject.notify({ state: { descriptors: currentState, loadingState }, update: currentState }); }); return { subscribe: (observer: Observer>) => { if (singletonObserver !== undefined) { throw new Error('Only one observer is allowed to be registered on this Observable.'); } singletonObserver = observer; updateSubject.subscribe(observer); return { unsubscribe: () => { updateSubject.unsubscribe(singletonObserver); subscription.unsubscribe(); cleanupCallbackQueue.cleanUp(); }, }; }, }; }; const componentDebugName = 'Schema Validation'; const gerErrorMessages = (errors: ValidationError[]): string[] => errors.map(({ error }) => error); const getWarningMessages = (errors: ValidationWarning[]): string[] => errors.map(({ warning }) => warning); /** * Validates the attributes against the schema. Returns true if the attributes are valid. */ const runValidation = (validator: Validator, payload: unknown, extensionPoint: string, extensionKey: string): boolean => { if (!validator) { throw new Error(`No validator specified to validate extension point "${extensionPoint}"`); } const { errors, warnings } = validator(payload); if (errors.length !== 0) { const errorMessages = gerErrorMessages(errors); onDebug(({ error }) => ({ level: error, message: `Schema validation for extension "${extensionKey}" returned errors.`, components: componentDebugName, meta: { location: extensionPoint, extension: extensionKey, errors: errorMessages, }, })); } if (warnings.length !== 0) { const warningMessages = getWarningMessages(warnings); onDebug(({ warn }) => ({ level: warn, message: `Schema validator for extension "${extensionKey}" returned warnings.`, components: componentDebugName, meta: { location: extensionPoint, extension: extensionKey, warnings: warningMessages, }, })); } return errors.length === 0; }; /** * Validates the context against the context schema. Returns true if the context is valid. */ const runContextValidation = (extensionPoint: string, validator: Validator | undefined, context: Context<{}> | null): boolean => { // This is a feedback for product developers and should not run in production if (process.env.NODE_ENV === 'production') { return true; } let errors: ValidationError[] = []; let warnings: ValidationWarning[] = []; if (context) { if (!validator) { throw new Error(`No context validator specified for extension point "${extensionPoint}"`); } ({ errors, warnings } = validator(context)); if (errors.length) { const errorMessages = gerErrorMessages(errors); onDebug(({ error }) => ({ level: error, message: `The context validation for extension-location "${extensionPoint}" returned errors.`, components: componentDebugName, meta: { extensionPoint, context, errors: errorMessages, }, })); } if (warnings.length) { const warningMessages = getWarningMessages(warnings); onDebug(({ warn }) => ({ level: warn, message: `The context validation for extension-location "${extensionPoint}" returned warnings.`, components: componentDebugName, meta: { extensionPoint, context, warnings: warningMessages, }, })); } } return errors.length === 0; }; type DescriptorMap = Map>; const transferDescriptorBetweenMaps = ( descriptor: ExtensionDescriptor, addToMap: DescriptorMap, removeFromMap: DescriptorMap, ) => { if (removeFromMap.has(descriptor.key)) { removeFromMap.delete(descriptor.key); } addToMap.set(descriptor.key, descriptor); }; export const getValidatedExtensions = | null, AttributesU extends ExtensionAttributes>( extensionPointName: string, context: ContextT, options: Options, ): Observable> => { const { contextValidator, attributeValidator } = options; const isContextValid = runContextValidation(extensionPointName, contextValidator, context); if (!isContextValid) { throw new Error(`The context provided for extension-location "${extensionPointName}" does not match the schema`); } const validatedExtensionsSubject = new ReplaySubject>(1); let singletonObserver: Observer>; const supportedDescriptors: DescriptorMap = new Map>(); const unsupportedDescriptors: DescriptorMap = new Map>(); const subscription = getExtensionPointSubscription(extensionPointName, context).subscribe( ({ state, update }) => { const { loadingState } = state; // only reevaluate the updated descriptors update.forEach((descriptor) => { if (!runValidation(attributeValidator, descriptor.attributes, extensionPointName, descriptor.key)) { transferDescriptorBetweenMaps(descriptor, unsupportedDescriptors, supportedDescriptors); } else { transferDescriptorBetweenMaps(descriptor, supportedDescriptors, unsupportedDescriptors); } }); validatedExtensionsSubject.notify([ Array.from(supportedDescriptors.values()), Array.from(unsupportedDescriptors.values()), loadingState, ]); }, ); return { subscribe: (observer: Observer>) => { if (singletonObserver !== undefined) { throw new Error('Only one observer is allowed to be registered on this Observable.'); } singletonObserver = observer; validatedExtensionsSubject.subscribe(observer); return { unsubscribe: () => { validatedExtensionsSubject.unsubscribe(singletonObserver); subscription.unsubscribe(); }, }; }, }; };