@elliemae/pui-app-bridge 2.25.0 → 2.26.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/cjs/appBridge.js +173 -26
  2. package/dist/cjs/config/app.js +8 -0
  3. package/dist/cjs/frame.html +0 -21
  4. package/dist/cjs/loaders/script.js +12 -12
  5. package/dist/cjs/tests/serverHandlers.js +0 -8
  6. package/dist/esm/appBridge.js +173 -26
  7. package/dist/esm/config/app.js +8 -0
  8. package/dist/esm/frame.html +0 -21
  9. package/dist/esm/loaders/script.js +12 -12
  10. package/dist/esm/tests/serverHandlers.js +0 -8
  11. package/dist/public/assets/{frame.671d9de68be598da64ca.html → frame.4cbbcfa9ded96b660559.html} +0 -21
  12. package/dist/public/e2e-host.html +1 -1
  13. package/dist/public/e2e-index.html +1 -1
  14. package/dist/public/frame.html +1 -1
  15. package/dist/public/index.html +1 -1
  16. package/dist/public/js/emuiAppBridge.dc4dbeb6feea171656da.js +17 -0
  17. package/dist/public/js/emuiAppBridge.dc4dbeb6feea171656da.js.br +0 -0
  18. package/dist/public/js/emuiAppBridge.dc4dbeb6feea171656da.js.gz +0 -0
  19. package/dist/public/js/emuiAppBridge.dc4dbeb6feea171656da.js.map +1 -0
  20. package/dist/types/lib/appBridge.d.ts +11 -2
  21. package/dist/types/lib/config/app.d.ts +6 -0
  22. package/dist/types/lib/loaders/script.d.ts +5 -3
  23. package/dist/types/lib/typings/host.d.ts +5 -0
  24. package/dist/types/lib/typings/window.d.ts +1 -1
  25. package/dist/types/tsconfig.tsbuildinfo +1 -1
  26. package/dist/umd/{671d9de68be598da64ca.html → 4cbbcfa9ded96b660559.html} +0 -21
  27. package/dist/umd/frame.html +1 -1
  28. package/dist/umd/index.js +7 -7
  29. package/dist/umd/index.js.br +0 -0
  30. package/dist/umd/index.js.gz +0 -0
  31. package/dist/umd/index.js.map +1 -1
  32. package/package.json +5 -5
  33. package/dist/public/js/emuiAppBridge.87b9f6d6c712609094fd.js +0 -17
  34. package/dist/public/js/emuiAppBridge.87b9f6d6c712609094fd.js.br +0 -0
  35. package/dist/public/js/emuiAppBridge.87b9f6d6c712609094fd.js.gz +0 -0
  36. package/dist/public/js/emuiAppBridge.87b9f6d6c712609094fd.js.map +0 -1
@@ -153,18 +153,19 @@ class CAppBridge {
153
153
  };
154
154
  /**
155
155
  * add app to active app list
156
- * @param param0
157
- * @param param0.id
158
- * @param param0.instanceId
159
- * @param elementIds
160
- * @param param0.documentEle
161
- * @param param0.elementIds
156
+ * @param options
157
+ * @param options.id - application id
158
+ * @param options.instanceId - unique instance id
159
+ * @param options.documentEle - iframe document
160
+ * @param options.elementIds - ids of injected script/style elements
161
+ * @param options.microFEHost - pre-created host reference for guest-initiated loading
162
162
  */
163
163
  #addAppToActiveAppList = ({
164
164
  id,
165
165
  instanceId,
166
166
  documentEle,
167
- elementIds
167
+ elementIds,
168
+ microFEHost
168
169
  }) => {
169
170
  const app = this.#appRegistry.get({ id, instanceId });
170
171
  if (!app) {
@@ -177,7 +178,8 @@ class CAppBridge {
177
178
  guest: {
178
179
  guestWindow: documentEle?.defaultView,
179
180
  ...app
180
- }
181
+ },
182
+ microFEHost
181
183
  });
182
184
  };
183
185
  #initApplication = async ({
@@ -189,7 +191,8 @@ class CAppBridge {
189
191
  homeRoute,
190
192
  initialRoute,
191
193
  history,
192
- theme
194
+ theme,
195
+ microFEHost
193
196
  }) => {
194
197
  const app = this.#appRegistry.get({ id, instanceId });
195
198
  if (!app) {
@@ -201,10 +204,8 @@ class CAppBridge {
201
204
  throw new Error(
202
205
  `Application ${id} with instance id ${instanceId} doesn't expose init method`
203
206
  );
204
- const host = new import_microfeHost.CMicroFEHost({
205
- guest: {
206
- id
207
- },
207
+ const host = microFEHost ?? new import_microfeHost.CMicroFEHost({
208
+ guest: { id },
208
209
  version: this.#version,
209
210
  containerId,
210
211
  logger: this.#logger,
@@ -232,7 +233,7 @@ class CAppBridge {
232
233
  `Application ${id} with instance id ${instanceId} is loading...`
233
234
  );
234
235
  let assets = files;
235
- const manifest = await import_loaders.ManifestLoader.get(options);
236
+ const manifest = options.manifest ?? await import_loaders.ManifestLoader.get(options);
236
237
  assets = import_loaders.ManifestLoader.getFullFileNameofAssets(manifest, files);
237
238
  const cssAssets = assets.filter((fileName) => isCss(fileName));
238
239
  const jsAssets = assets.filter((fileName) => !isCss(fileName));
@@ -250,9 +251,12 @@ class CAppBridge {
250
251
  id,
251
252
  instanceId,
252
253
  documentEle,
253
- elementIds: [...styleElementIds, ...scriptElementIds]
254
+ elementIds: [...styleElementIds, ...scriptElementIds],
255
+ microFEHost: options.microFEHost
254
256
  });
255
- await this.#initApplication(options);
257
+ if (!options.selfInitialize) {
258
+ await this.#initApplication(options);
259
+ }
256
260
  this.#logger.audit(
257
261
  `Application ${id} with instance id ${instanceId} loaded`
258
262
  );
@@ -351,6 +355,56 @@ class CAppBridge {
351
355
  });
352
356
  }
353
357
  };
358
+ /**
359
+ * If the guest's module SO exposes _setUnloadHandler (duck-typed),
360
+ * bind it to closeApp so the guest can trigger its own teardown.
361
+ * @param id
362
+ * @param instanceId
363
+ */
364
+ #bindModuleUnload = (id, instanceId) => {
365
+ const moduleSO = this.#soManager.getObject(
366
+ import_pui_scripting_object.ScriptingObjectNames.Module,
367
+ { id }
368
+ );
369
+ const so = moduleSO;
370
+ const handler = so?._setUnloadHandler;
371
+ if (typeof handler === "function") {
372
+ handler(
373
+ () => this.closeApp(instanceId)
374
+ );
375
+ }
376
+ };
377
+ /**
378
+ * Dispatch the module.unloading event and collect feedback.
379
+ * Returns true if unload is allowed, false if any listener vetoed.
380
+ * @param id
381
+ */
382
+ #canUnload = async (id) => {
383
+ const moduleSO = this.#soManager.getObject(
384
+ import_pui_scripting_object.ScriptingObjectNames.Module,
385
+ { id }
386
+ );
387
+ if (!moduleSO) return true;
388
+ try {
389
+ const feedback = await this.#eventManager.dispatchEvent(moduleSO, {
390
+ event: { id: "module.unloading", name: "unloading" },
391
+ eventParams: { moduleId: id },
392
+ eventOptions: { timeout: 1e3 }
393
+ });
394
+ if (Array.isArray(feedback) && feedback.some((v) => v === false)) {
395
+ this.#logger.info(
396
+ `Unload denied by guest ${id} via module.unloading event`
397
+ );
398
+ return false;
399
+ }
400
+ } catch (err) {
401
+ this.#logger.warn({
402
+ message: `Error dispatching module.unloading event for ${id}`,
403
+ exception: err
404
+ });
405
+ }
406
+ return true;
407
+ };
354
408
  /**
355
409
  * clear session management for the guest application
356
410
  * @param instanceId unique instance id of the application
@@ -483,6 +537,7 @@ class CAppBridge {
483
537
  return;
484
538
  }
485
539
  const { id } = app;
540
+ if (!await this.#canUnload(id)) return;
486
541
  const appConfig = this.#microFEConfig.getConfigById(id);
487
542
  if (!appConfig) {
488
543
  this.#logger.warn(`Configuration for application ${id} is not found`);
@@ -543,14 +598,46 @@ class CAppBridge {
543
598
  */
544
599
  getApps = () => [...this.#activeApps.values()].map((app) => app.guest);
545
600
  /**
546
- * Initialize appBridge
601
+ * Initialize appBridge.
602
+ * @param preloadedAppConfig - Already-parsed config object. When provided
603
+ * the bridge skips its own HTTP fetch of app.config.json.
547
604
  */
548
- init = async () => {
549
- await this.#appConfig.load();
605
+ init = async (preloadedAppConfig) => {
606
+ if (preloadedAppConfig) {
607
+ this.#appConfig.setPreloadedConfig(preloadedAppConfig);
608
+ } else {
609
+ await this.#appConfig.load();
610
+ }
550
611
  this.#microFEConfig.init({
551
612
  version: this.#version,
552
613
  appConfig: this.#appConfig
553
614
  });
615
+ this.#injectPreconnects();
616
+ };
617
+ /**
618
+ * Inject `<link rel="preconnect">` tags into the parent document for each
619
+ * unique micro-app origin. Called once at the end of {@link init} so that
620
+ * DNS/TCP/TLS handshakes start before any iframe is created.
621
+ */
622
+ #injectPreconnects = () => {
623
+ const apps = this.#appConfig.get("microFrontendApps") ?? {};
624
+ const origins = /* @__PURE__ */ new Set();
625
+ Object.keys(apps).forEach((id) => {
626
+ const cfg = this.#microFEConfig.getConfigById(id);
627
+ if (cfg?.hostUrl) {
628
+ try {
629
+ origins.add(new URL(cfg.hostUrl).origin);
630
+ } catch {
631
+ }
632
+ }
633
+ });
634
+ origins.forEach((origin) => {
635
+ const link = document.createElement("link");
636
+ link.rel = "preconnect";
637
+ link.href = origin;
638
+ link.crossOrigin = "anonymous";
639
+ document.head.appendChild(link);
640
+ });
554
641
  };
555
642
  /**
556
643
  * Mount guest micro frontend application into DOM
@@ -571,10 +658,42 @@ class CAppBridge {
571
658
  }
572
659
  await this.#mountApp({ ...appConfig, instanceId });
573
660
  };
661
+ /**
662
+ * Prepare the iframe: create a CMicroFEHost, expose it on window.emui,
663
+ * and inject a preconnect hint for the guest origin.
664
+ * @param frameDoc
665
+ * @param appConfig
666
+ * @param appConfig.hostUrl
667
+ * @param options
668
+ * @param options.id
669
+ * @param options.containerId
670
+ */
671
+ #setupIframe = (frameDoc, appConfig, options) => {
672
+ const microFEHost = new import_microfeHost.CMicroFEHost({
673
+ guest: { id: options.id },
674
+ version: this.#version,
675
+ containerId: options.containerId,
676
+ logger: this.#logger,
677
+ soManager: this.#soManager,
678
+ eventManager: this.#eventManager
679
+ });
680
+ const iframeWindow = frameDoc.defaultView;
681
+ if (iframeWindow) {
682
+ iframeWindow.emui = iframeWindow.emui ?? {};
683
+ iframeWindow.emui.__host = microFEHost;
684
+ }
685
+ const preconnect = frameDoc.createElement("link");
686
+ preconnect.rel = "preconnect";
687
+ preconnect.href = appConfig.hostUrl;
688
+ preconnect.crossOrigin = "anonymous";
689
+ frameDoc.head.appendChild(preconnect);
690
+ return microFEHost;
691
+ };
574
692
  /**
575
693
  * Open guest micro frontend application
576
694
  * @param {OpenAppParams} params - options to open guest application
577
695
  */
696
+ // eslint-disable-next-line max-statements
578
697
  openApp = async (params) => {
579
698
  const {
580
699
  id,
@@ -583,7 +702,8 @@ class CAppBridge {
583
702
  theme,
584
703
  homeRoute,
585
704
  initialRoute,
586
- metadata
705
+ metadata,
706
+ selfInitialize
587
707
  } = params;
588
708
  const instanceId = (0, import_uuid.v4)();
589
709
  if (metadata) this.#guestMetadata.set(id, metadata);
@@ -591,15 +711,15 @@ class CAppBridge {
591
711
  if (!appConfig) {
592
712
  throw new Error(`Application ${id} is not found in app config`);
593
713
  }
714
+ const manifestPromise = import_loaders.ManifestLoader.get(appConfig);
715
+ manifestPromise.catch(() => {
716
+ });
594
717
  const frameEle = await import_frame.Frame.create({
595
718
  id,
596
719
  instanceId,
597
720
  manifestPath: appConfig.manifestPath,
598
721
  hostUrl: appConfig.hostUrl,
599
- options: {
600
- title: appConfig.name,
601
- ...frameOptions
602
- }
722
+ options: { title: appConfig.name, ...frameOptions }
603
723
  });
604
724
  if (!frameEle?.contentDocument)
605
725
  throw new Error("unable to create iframe for the microapp");
@@ -609,6 +729,15 @@ class CAppBridge {
609
729
  instanceId,
610
730
  documentEle: frameEle.contentDocument
611
731
  });
732
+ const microFEHost = this.#setupIframe(
733
+ frameEle.contentDocument,
734
+ appConfig,
735
+ {
736
+ id,
737
+ containerId: frameOptions?.containerId
738
+ }
739
+ );
740
+ const manifest = await manifestPromise;
612
741
  await this.#loadApp({
613
742
  instanceId,
614
743
  history,
@@ -617,10 +746,16 @@ class CAppBridge {
617
746
  containerId: frameOptions?.containerId,
618
747
  ...appConfig,
619
748
  homeRoute: homeRoute ?? appConfig.homeRoute,
620
- initialRoute
749
+ initialRoute,
750
+ microFEHost,
751
+ selfInitialize,
752
+ manifest
621
753
  });
622
- await this.#mountApp({ instanceId, ...appConfig });
754
+ if (!selfInitialize) {
755
+ await this.#mountApp({ instanceId, ...appConfig });
756
+ }
623
757
  this.#manageSession({ id, instanceId });
758
+ this.#bindModuleUnload(id, instanceId);
624
759
  return instanceId;
625
760
  } catch (err) {
626
761
  this.#unloadApp({
@@ -654,6 +789,18 @@ class CAppBridge {
654
789
  removeScriptingObject = (objectId, guestId) => {
655
790
  this.#soManager.removeScriptingObject(objectId, guestId);
656
791
  };
792
+ /**
793
+ * Pre-fetch the manifest for a guest application so that a subsequent
794
+ * {@link openApp} call can skip the manifest network round-trip.
795
+ * Safe to call multiple times — results are cached.
796
+ * @param id application id as defined in app.config.json
797
+ */
798
+ warmUp = (id) => {
799
+ const appConfig = this.#microFEConfig.getConfigById(id);
800
+ if (!appConfig) return;
801
+ import_loaders.ManifestLoader.get(appConfig).catch(() => {
802
+ });
803
+ };
657
804
  /**
658
805
  * Unmount guest micro frontend application from DOM
659
806
  * @param instanceId unique instance id of guest micro frontend application
@@ -85,6 +85,14 @@ class CAppConfig {
85
85
  * @returns true if key exists
86
86
  */
87
87
  has = (key = "") => (0, import_has.default)(this.#gAppConfig, key);
88
+ /**
89
+ * Accept an already-parsed config object, skipping the HTTP fetch.
90
+ * Useful when the caller (e.g. pui-app-sdk) has already loaded the config.
91
+ * @param config
92
+ */
93
+ setPreloadedConfig = (config) => {
94
+ this.#gAppConfig = config;
95
+ };
88
96
  /**
89
97
  * add version to the base url
90
98
  * @returns versioned base url
@@ -6,27 +6,6 @@
6
6
  <meta name="mobile-web-app-capable" content="yes" />
7
7
  <link rel="icon" href="/favicon.ico" />
8
8
  <title>Application</title>
9
- <script nonce="__CSP_NONCE__">
10
- (function (i, s, o, g, r, a, m) {
11
- i['GoogleAnalyticsObject'] = r;
12
- (i[r] =
13
- i[r] ||
14
- function () {
15
- (i[r].q = i[r].q || []).push(arguments);
16
- }),
17
- (i[r].l = 1 * new Date());
18
- (a = s.createElement(o)), (m = s.getElementsByTagName(o)[0]);
19
- a.async = 1;
20
- a.src = g;
21
- m.parentNode.insertBefore(a, m);
22
- })(
23
- window,
24
- document,
25
- 'script',
26
- 'https://www.google-analytics.com/analytics.js',
27
- 'ga',
28
- );
29
- </script>
30
9
  <style nonce="__CSP_NONCE__">
31
10
  .full-width {
32
11
  width: 100%;
@@ -121,9 +121,11 @@ class ScriptLoader {
121
121
  * This method:
122
122
  * - Partitions assets into CDN (HTTP/HTTPS) and non-CDN (relative) arrays in a single pass
123
123
  * - Creates modulepreload links for all assets for performance optimization
124
- * - Loads CDN scripts sequentially first (they may set global variables needed by non-CDN scripts)
125
- * - Then loads non-CDN scripts sequentially
126
- * - Sequential execution ensures proper dependency resolution and initialization order
124
+ * - Appends all script elements to the DOM synchronously so the browser
125
+ * can download them in parallel
126
+ * - Execution order is preserved by the spec: type="module" scripts
127
+ * execute in document order; dynamic async=false scripts execute in
128
+ * insertion order
127
129
  * @example
128
130
  * ```typescript
129
131
  * const scriptIds = await scriptLoader.load(
@@ -158,15 +160,13 @@ class ScriptLoader {
158
160
  nonCdnAssets: []
159
161
  }
160
162
  );
161
- for (const { href, id } of [...cdnAssets, ...nonCdnAssets]) {
162
- await this.#loadScript({
163
- id,
164
- href,
165
- documentEle,
166
- isESMModule
167
- });
168
- }
169
- return [...cdnAssets, ...nonCdnAssets].map((asset) => asset.id);
163
+ const orderedAssets = [...cdnAssets, ...nonCdnAssets];
164
+ await Promise.all(
165
+ orderedAssets.map(
166
+ ({ id, href }) => this.#loadScript({ id, href, documentEle, isESMModule })
167
+ )
168
+ );
169
+ return orderedAssets.map((asset) => asset.id);
170
170
  };
171
171
  /**
172
172
  * Removes a script element from the document by its ID.
@@ -155,14 +155,6 @@ const getServerHandlers = () => {
155
155
  return versionedHandlers;
156
156
  };
157
157
  const serverHandlers = [
158
- import_msw.rest.get(
159
- "https://www.google-analytics.com/analytics.js",
160
- (req, res, ctx) => res(
161
- ctx.status(200),
162
- ctx.set("Content-Type", "application/javascript"),
163
- ctx.body("")
164
- )
165
- ),
166
158
  import_msw.rest.get(
167
159
  "/latest/app.config.json",
168
160
  (req, res, ctx) => res(ctx.json(import_app_config.default))