|
1 | | -import { PluginStore, FeatureFlags, PluginLoaderOptions, PluginStoreOptions, PluginManifest } from '@openshift/dynamic-plugin-sdk'; |
2 | | -import { warnDuplicatePkg } from './warnDuplicatePkg'; |
3 | | -export const GLOBAL_NAMESPACE = '__scalprum__'; |
4 | | -export type AppMetadata<T extends {} = {}> = T & { |
5 | | - name: string; |
6 | | - appId?: string; |
7 | | - elementId?: string; |
8 | | - rootLocation?: string; |
9 | | - scriptLocation?: string; |
10 | | - manifestLocation?: string; |
11 | | - pluginManifest?: PluginManifest; |
12 | | -}; |
13 | | -export interface AppsConfig<T extends {} = {}> { |
14 | | - [key: string]: AppMetadata<T>; |
15 | | -} |
16 | | - |
17 | | -export type PrefetchFunction<T = any> = (ScalprumApi: Record<string, any> | undefined) => Promise<T>; |
18 | | -export type ExposedScalprumModule<T = any, P = any> = { [importName: string]: T } & { prefetch?: PrefetchFunction<P> }; |
19 | | -export type ScalprumModule<T = any, P = any> = { |
20 | | - cachedModule?: ExposedScalprumModule<T, P>; |
21 | | - prefetchPromise?: ReturnType<PrefetchFunction>; |
22 | | -}; |
23 | | - |
24 | | -export interface Factory<T = any, P = any> { |
25 | | - init: (sharing: any) => void; |
26 | | - modules: { |
27 | | - [key: string]: ExposedScalprumModule<T, P>; |
28 | | - }; |
29 | | - expiration: Date; |
30 | | -} |
31 | | - |
32 | | -export interface ScalprumOptions { |
33 | | - cacheTimeout: number; |
34 | | - enableScopeWarning: boolean; |
35 | | -} |
36 | | - |
37 | | -export type Scalprum<T extends Record<string, any> = Record<string, any>> = { |
38 | | - appsConfig: AppsConfig; |
39 | | - pendingInjections: { |
40 | | - [key: string]: Promise<any>; |
41 | | - }; |
42 | | - pendingLoading: { |
43 | | - [key: string]: Promise<ScalprumModule>; |
44 | | - }; |
45 | | - pendingPrefetch: { |
46 | | - [key: string]: Promise<unknown>; |
47 | | - }; |
48 | | - existingScopes: Set<string>; |
49 | | - exposedModules: { |
50 | | - [moduleId: string]: ExposedScalprumModule; |
51 | | - }; |
52 | | - scalprumOptions: ScalprumOptions; |
53 | | - api: T; |
54 | | - pluginStore: PluginStore; |
55 | | -}; |
56 | | - |
57 | | -export type Container = Window & Factory; |
58 | | - |
59 | | -declare function __webpack_init_sharing__(scope: string): void; |
60 | | -declare let __webpack_share_scopes__: any; |
61 | | - |
62 | | -const SHARED_SCOPE_NAME = 'default'; |
63 | | - |
64 | | -let scalprum: Scalprum | undefined; |
65 | | - |
66 | | -export const getModuleIdentifier = (scope: string, module: string) => `${scope}#${module}`; |
67 | | - |
68 | | -export const getScalprum = () => { |
69 | | - if (!scalprum) { |
70 | | - throw new Error('Scalprum was not initialized! Call the initialize function first.'); |
71 | | - } |
72 | | - |
73 | | - return scalprum; |
74 | | -}; |
75 | | - |
76 | | -export const initSharedScope = async () => __webpack_init_sharing__(SHARED_SCOPE_NAME); |
77 | | - |
78 | | -/** |
79 | | - * Get the webpack share scope object. |
80 | | - */ |
81 | | -export const getSharedScope = (enableScopeWarning?: boolean) => { |
82 | | - if (!Object.keys(__webpack_share_scopes__).includes(SHARED_SCOPE_NAME)) { |
83 | | - throw new Error('Attempt to access share scope object before its initialization'); |
84 | | - } |
85 | | - |
86 | | - const sharedScope = __webpack_share_scopes__[SHARED_SCOPE_NAME]; |
87 | | - if (enableScopeWarning) { |
88 | | - warnDuplicatePkg(sharedScope); |
89 | | - } |
90 | | - |
91 | | - return sharedScope; |
92 | | -}; |
93 | | - |
94 | | -export const handlePrefetchPromise = (id: string, prefetch?: Promise<any>) => { |
95 | | - if (prefetch) { |
96 | | - setPendingPrefetch(id, prefetch); |
97 | | - prefetch.finally(() => { |
98 | | - removePrefetch(id); |
99 | | - }); |
100 | | - } |
101 | | -}; |
102 | | - |
103 | | -export const getCachedModule = <T = any, P = any>(scope: string, module: string): ScalprumModule<T, P> => { |
104 | | - const moduleId = getModuleIdentifier(scope, module); |
105 | | - try { |
106 | | - const cachedModule = getScalprum().exposedModules[moduleId]; |
107 | | - if (!module) { |
108 | | - return {}; |
109 | | - } |
110 | | - |
111 | | - const prefetchID = `${scope}#${module}`; |
112 | | - const prefetchPromise = getPendingPrefetch(prefetchID); |
113 | | - if (prefetchPromise) { |
114 | | - return { cachedModule, prefetchPromise }; |
115 | | - } |
116 | | - if (cachedModule?.prefetch) { |
117 | | - handlePrefetchPromise(prefetchID, cachedModule.prefetch(getScalprum().api)); |
118 | | - return { cachedModule, prefetchPromise: getPendingPrefetch(prefetchID) }; |
119 | | - } |
120 | | - return { cachedModule }; |
121 | | - } catch (error) { |
122 | | - // If something goes wrong during the cache retrieval, reload module. |
123 | | - console.warn(`Unable to retrieve cached module ${scope} ${module}. New module will be loaded.`, error); |
124 | | - return {}; |
125 | | - } |
126 | | -}; |
127 | | - |
128 | | -export const setPendingPrefetch = (id: string, prefetch: Promise<any>): void => { |
129 | | - getScalprum().pendingPrefetch[id] = prefetch; |
130 | | -}; |
131 | | - |
132 | | -export const getPendingPrefetch = (id: string): Promise<any> | undefined => { |
133 | | - return getScalprum().pendingPrefetch?.[id]; |
134 | | -}; |
135 | | - |
136 | | -export const removePrefetch = (id: string) => { |
137 | | - delete getScalprum().pendingPrefetch[id]; |
138 | | -}; |
139 | | - |
140 | | -export const resolvePendingInjection = (id: string) => { |
141 | | - delete getScalprum().pendingInjections[id]; |
142 | | -}; |
143 | | - |
144 | | -export const setPendingLoading = (scope: string, module: string, promise: Promise<any>): Promise<any> => { |
145 | | - getScalprum().pendingLoading[`${scope}#${module}`] = promise; |
146 | | - promise |
147 | | - .then((data) => { |
148 | | - delete getScalprum().pendingLoading[`${scope}#${module}`]; |
149 | | - return data; |
150 | | - }) |
151 | | - .catch(() => { |
152 | | - delete getScalprum().pendingLoading[`${scope}#${module}`]; |
153 | | - }); |
154 | | - return promise; |
155 | | -}; |
156 | | - |
157 | | -export const getPendingLoading = (scope: string, module: string): Promise<any> | undefined => { |
158 | | - return getScalprum().pendingLoading[`${scope}#${module}`]; |
159 | | -}; |
160 | | - |
161 | | -export const preloadModule = async (scope: string, module: string, processor?: (manifest: any) => string[]) => { |
162 | | - const { manifestLocation } = getAppData(scope); |
163 | | - const { cachedModule } = getCachedModule(scope, module); |
164 | | - let modulePromise = getPendingLoading(scope, module); |
165 | | - |
166 | | - // lock preloading if module exists or is already being loaded |
167 | | - if (!modulePromise && Object.keys(cachedModule || {}).length == 0 && manifestLocation) { |
168 | | - modulePromise = processManifest(manifestLocation, scope, module, processor).then(() => getScalprum().pluginStore.getExposedModule(scope, module)); |
169 | | - } |
170 | | - |
171 | | - // add scalprum API later |
172 | | - const prefetchID = `${scope}#${module}`; |
173 | | - |
174 | | - if (!getPendingPrefetch(prefetchID) && cachedModule?.prefetch) { |
175 | | - handlePrefetchPromise(prefetchID, cachedModule.prefetch(getScalprum().api)); |
176 | | - } |
177 | | - |
178 | | - return setPendingLoading(scope, module, Promise.resolve(modulePromise)); |
179 | | -}; |
180 | | - |
181 | | -export const getModule = async <T = any, P = any>(scope: string, module: string, importName = 'default'): Promise<T> => { |
182 | | - const scalprum = getScalprum(); |
183 | | - const { cachedModule } = getCachedModule(scope, module); |
184 | | - let Module: ExposedScalprumModule<T, P>; |
185 | | - const manifestLocation = getAppData(scope)?.manifestLocation; |
186 | | - if (!manifestLocation) { |
187 | | - throw new Error(`Could not get module. Manifest location not found for scope ${scope}.`); |
188 | | - } |
189 | | - if (!cachedModule) { |
190 | | - try { |
191 | | - await processManifest(manifestLocation, scope, module); |
192 | | - Module = await scalprum.pluginStore.getExposedModule(scope, module); |
193 | | - } catch { |
194 | | - throw new Error( |
195 | | - `Module not initialized! Module "${module}" was not found in "${scope}" webpack scope. Make sure the remote container is loaded?`, |
196 | | - ); |
197 | | - } |
198 | | - } else { |
199 | | - Module = cachedModule; |
200 | | - } |
201 | | - |
202 | | - return Module[importName]; |
203 | | -}; |
204 | | - |
205 | | -export const initialize = <T extends Record<string, any> = Record<string, any>>({ |
206 | | - appsConfig, |
207 | | - api, |
208 | | - options, |
209 | | - pluginStoreFeatureFlags = {}, |
210 | | - pluginLoaderOptions = {}, |
211 | | - pluginStoreOptions = {}, |
212 | | -}: { |
213 | | - appsConfig: AppsConfig; |
214 | | - api?: T; |
215 | | - options?: Partial<ScalprumOptions>; |
216 | | - pluginStoreFeatureFlags?: FeatureFlags; |
217 | | - pluginLoaderOptions?: PluginLoaderOptions; |
218 | | - pluginStoreOptions?: PluginStoreOptions; |
219 | | -}): Scalprum<T> => { |
220 | | - if (scalprum) { |
221 | | - scalprum.api = api || {}; |
222 | | - scalprum.appsConfig = appsConfig; |
223 | | - scalprum.scalprumOptions = { |
224 | | - ...scalprum.scalprumOptions, |
225 | | - ...options, |
226 | | - }; |
227 | | - scalprum.pluginStore.setFeatureFlags(pluginStoreFeatureFlags); |
228 | | - return scalprum as Scalprum<T>; |
229 | | - } |
230 | | - const defaultOptions: ScalprumOptions = { |
231 | | - cacheTimeout: 120, |
232 | | - enableScopeWarning: global?.process?.env?.NODE_ENV === 'development', |
233 | | - ...options, |
234 | | - }; |
235 | | - |
236 | | - // Create new plugin store |
237 | | - const pluginStore = new PluginStore({ |
238 | | - ...pluginStoreOptions, |
239 | | - loaderOptions: { |
240 | | - sharedScope: getSharedScope(defaultOptions.enableScopeWarning), |
241 | | - getPluginEntryModule: ({ name }) => (window as { [key: string]: any })[name], |
242 | | - ...pluginLoaderOptions, |
243 | | - }, |
244 | | - }); |
245 | | - pluginStore.setFeatureFlags(pluginStoreFeatureFlags); |
246 | | - |
247 | | - scalprum = { |
248 | | - appsConfig, |
249 | | - pendingInjections: {}, |
250 | | - pendingLoading: {}, |
251 | | - pendingPrefetch: {}, |
252 | | - existingScopes: new Set<string>(), |
253 | | - exposedModules: {}, |
254 | | - scalprumOptions: defaultOptions, |
255 | | - api: api || {}, |
256 | | - pluginStore, |
257 | | - }; |
258 | | - |
259 | | - return scalprum as Scalprum<T>; |
260 | | -}; |
261 | | - |
262 | | -export const removeScalprum = () => { |
263 | | - scalprum = undefined; |
264 | | -}; |
265 | | - |
266 | | -export const getAppData = (name: string): AppMetadata => getScalprum().appsConfig[name]; |
267 | | - |
268 | | -const setExposedModule = (scope: string, module: string, exposedModule: ExposedScalprumModule) => { |
269 | | - if (!getScalprum().existingScopes.has(scope)) { |
270 | | - getScalprum().existingScopes.add(scope); |
271 | | - } |
272 | | - const moduleId = getModuleIdentifier(scope, module); |
273 | | - getScalprum().exposedModules[moduleId] = exposedModule; |
274 | | -}; |
275 | | - |
276 | | -const clearPendingInjection = (scope: string) => { |
277 | | - delete getScalprum().pendingInjections[scope]; |
278 | | -}; |
279 | | - |
280 | | -const setPendingInjection = (scope: string, promise: Promise<any>) => { |
281 | | - getScalprum().pendingInjections[scope] = promise; |
282 | | -}; |
283 | | - |
284 | | -const getPendingInjection = (scope: string): Promise<any> | undefined => getScalprum().pendingInjections[scope]; |
285 | | - |
286 | | -// PluginManifest typeguard |
287 | | -function isPluginManifest(manifest: any): manifest is PluginManifest { |
288 | | - return ( |
289 | | - typeof manifest.name === 'string' && |
290 | | - typeof manifest.version === 'string' && |
291 | | - Array.isArray(manifest.extensions) && |
292 | | - Array.isArray(manifest.loadScripts) |
293 | | - ); |
294 | | -} |
295 | | - |
296 | | -function extractBaseURL(path: string) { |
297 | | - const result = path.split('/'); |
298 | | - // remove last section of pathname that includes the JS filename |
299 | | - result.pop(); |
300 | | - // make sure there is always at least leading / to satisfy sdk manifest validation |
301 | | - return result.join('/') || '/'; |
302 | | -} |
303 | | - |
304 | | -export async function processManifest( |
305 | | - moduleManifest: string | PluginManifest, |
306 | | - scope: string, |
307 | | - module: string, |
308 | | - processor?: (manifest: any) => string[], |
309 | | -): Promise<void> { |
310 | | - let pendingInjection = getPendingInjection(scope); |
311 | | - const { pluginStore, existingScopes } = getScalprum(); |
312 | | - |
313 | | - if (existingScopes.has(scope)) { |
314 | | - try { |
315 | | - const exposedModule = await pluginStore.getExposedModule<ExposedScalprumModule>(scope, module); |
316 | | - setExposedModule(scope, module, exposedModule); |
317 | | - return; |
318 | | - } catch (error) { |
319 | | - console.warn('Unable to load module from existing container', error); |
320 | | - console.warn('Scalprum will try to process manifest from scratch.'); |
321 | | - } |
322 | | - } |
323 | | - |
324 | | - if (pendingInjection) { |
325 | | - await pendingInjection; |
326 | | - const exposedModule = await pluginStore.getExposedModule<ExposedScalprumModule>(scope, module); |
327 | | - setExposedModule(scope, module, exposedModule); |
328 | | - return; |
329 | | - } |
330 | | - |
331 | | - pendingInjection = (async () => { |
332 | | - let manifest: PluginManifest | { [scope: string]: { entry: string[] } }; |
333 | | - if (typeof moduleManifest === 'object') { |
334 | | - manifest = moduleManifest; |
335 | | - } else { |
336 | | - const headers = new Headers(); |
337 | | - headers.append('Pragma', 'no-cache'); |
338 | | - headers.append('Cache-Control', 'no-cache'); |
339 | | - headers.append('expires', '0'); |
340 | | - const manifestPromise = await fetch(moduleManifest, { |
341 | | - method: 'GET', |
342 | | - headers, |
343 | | - }); |
344 | | - // handle network errors |
345 | | - if (!manifestPromise.ok) { |
346 | | - const resClone = manifestPromise.clone(); |
347 | | - let data; |
348 | | - try { |
349 | | - data = await resClone.json(); |
350 | | - } catch (error) { |
351 | | - throw new Error(`Unable to load manifest files at ${moduleManifest}! ${resClone.status}: ${resClone.statusText}`); |
352 | | - } |
353 | | - throw new Error(`Unable to load manifest files at ${moduleManifest}! ${data}`); |
354 | | - } |
355 | | - try { |
356 | | - manifest = await manifestPromise.json(); |
357 | | - } catch (error) { |
358 | | - clearPendingInjection(scope); |
359 | | - throw new Error(error as string); |
360 | | - } |
361 | | - } |
362 | | - let sdkManifest: PluginManifest; |
363 | | - if (isPluginManifest(manifest)) { |
364 | | - sdkManifest = manifest; |
365 | | - } else { |
366 | | - const loadScripts: string[] = processor ? processor(manifest) : manifest[scope].entry; |
367 | | - const baseURL = extractBaseURL(loadScripts[0]); |
368 | | - sdkManifest = { |
369 | | - extensions: [], |
370 | | - // remove base URL from script entry, baseURL is added by scalprum provider |
371 | | - loadScripts: loadScripts.map((script) => script.replace(baseURL, '')), |
372 | | - name: scope, |
373 | | - registrationMethod: 'custom', |
374 | | - version: '1.0.0', |
375 | | - baseURL, |
376 | | - }; |
377 | | - } |
378 | | - |
379 | | - await pluginStore.loadPlugin(sdkManifest); |
380 | | - try { |
381 | | - const exposedModule = await pluginStore.getExposedModule<ExposedScalprumModule>(scope, module); |
382 | | - setExposedModule(scope, module, exposedModule); |
383 | | - return; |
384 | | - } catch (error) { |
385 | | - clearPendingInjection(scope); |
386 | | - throw error; |
387 | | - } |
388 | | - })(); |
389 | | - |
390 | | - setPendingInjection(scope, pendingInjection); |
391 | | - await pendingInjection; |
392 | | - clearPendingInjection(scope); |
393 | | -} |
| 1 | +export * from './scalprum'; |
| 2 | +export * from './createSharedStore'; |
| 3 | +export * from './warnDuplicatePkg'; |
0 commit comments