@equinor/fusion-framework-vite-plugin-spa 4.1.0 → 4.1.2

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.
package/rollup.config.js DELETED
@@ -1,20 +0,0 @@
1
- import resolve from '@rollup/plugin-node-resolve';
2
- import commonjs from '@rollup/plugin-commonjs';
3
-
4
- /** @type {import('rollup').RollupOptions} */
5
- export default [
6
- {
7
- input: {
8
- 'html/bootstrap': 'dist/esm/html/bootstrap.js',
9
- 'html/sw': 'dist/esm/html/sw.js',
10
- },
11
- output: {
12
- format: 'esm',
13
- dir: 'dist',
14
- entryFileNames: '[name].js',
15
- sourcemap: true,
16
- strict: false,
17
- },
18
- plugins: [resolve({ preferBuiltins: true }), commonjs()],
19
- },
20
- ];
@@ -1,209 +0,0 @@
1
- import { ModulesConfigurator } from '@equinor/fusion-framework-module';
2
-
3
- import { configureHttpClient, type HttpModule } from '@equinor/fusion-framework-module-http';
4
- import { enableMSAL, type MsalModule } from '@equinor/fusion-framework-module-msal';
5
- import {
6
- enableServiceDiscovery,
7
- type ServiceDiscoveryModule,
8
- } from '@equinor/fusion-framework-module-service-discovery';
9
-
10
- import {
11
- enableTelemetry,
12
- TelemetryLevel,
13
- type TelemetryModule,
14
- } from '@equinor/fusion-framework-module-telemetry';
15
- import { ConsoleAdapter } from '@equinor/fusion-framework-module-telemetry/console-adapter';
16
-
17
- import { createPortalEntryPoint } from './create-portal-entry-point.js';
18
- import { isEnabledEnvValue } from './is-enabled-env-value.js';
19
- import { registerServiceWorker } from './register-service-worker.js';
20
-
21
- import { version } from '../version.js';
22
-
23
- // TODO(#5065): add type for portal manifest when available
24
- type PortalManifest = {
25
- build: {
26
- config: Record<string, unknown>;
27
- templateEntry: string;
28
- assetPath?: string;
29
- };
30
- };
31
-
32
- // Allow dynamic import without vite
33
- const importWithoutVite = <T>(path: string): Promise<T> => import(/* @vite-ignore */ path);
34
-
35
- // Create Fusion Framework configurator
36
- const configurator = new ModulesConfigurator();
37
-
38
- const serviceDiscoveryUrl = new URL(
39
- import.meta.env.FUSION_SPA_SERVICE_DISCOVERY_URL,
40
- import.meta.env.FUSION_SPA_SERVICE_DISCOVERY_URL.startsWith('http')
41
- ? undefined
42
- : window.location.origin,
43
- );
44
-
45
- // define service discovery client - this is used in the service discovery module
46
- configurator.addConfig(
47
- configureHttpClient('service_discovery', {
48
- baseUri: String(serviceDiscoveryUrl),
49
- defaultScopes: import.meta.env.FUSION_SPA_SERVICE_DISCOVERY_SCOPES,
50
- }),
51
- );
52
-
53
- // setup service discovery - enable service discovery for the framework
54
- enableServiceDiscovery(configurator, async (builder) => {
55
- builder.configureServiceDiscoveryClientByClientKey('service_discovery');
56
- });
57
-
58
- // Avoid interactive Entra ID authentication when the SPA runs in CI or Playwright.
59
- if (isEnabledEnvValue(import.meta.env.FUSION_SPA_MSAL_MOCK)) {
60
- const { enableMsalMock } = await import('@equinor/fusion-framework-module-msal/mock');
61
-
62
- const mockToken = import.meta.env.FUSION_SPA_MSAL_MOCK_TOKEN;
63
-
64
- enableMsalMock(configurator, (builder) => {
65
- // Preserve the mock client's default identity when no backend-specific token is configured.
66
- if (!mockToken) return;
67
-
68
- builder.setToken(mockToken);
69
- });
70
- } else {
71
- enableMSAL(configurator, (builder) => {
72
- builder.setClientConfig({
73
- auth: {
74
- clientId: import.meta.env.FUSION_SPA_MSAL_CLIENT_ID,
75
- tenantId: import.meta.env.FUSION_SPA_MSAL_TENANT_ID,
76
- redirectUri: import.meta.env.FUSION_SPA_MSAL_REDIRECT_URI,
77
- },
78
- });
79
-
80
- builder.setRequiresAuth(isEnabledEnvValue(import.meta.env.FUSION_SPA_MSAL_REQUIRES_AUTH));
81
- });
82
- }
83
-
84
- enableTelemetry(configurator, {
85
- attachConfiguratorEvents: true,
86
- configure: (builder) => {
87
- const consoleLevel = Number(
88
- import.meta.env.FUSION_SPA_TELEMETRY_CONSOLE_LEVEL ?? TelemetryLevel.Information,
89
- );
90
-
91
- const consoleAdapter = new ConsoleAdapter({
92
- filter: Number.isNaN(consoleLevel) ? undefined : (item) => item.level >= consoleLevel,
93
- });
94
-
95
- builder.setAdapter('console', consoleAdapter);
96
-
97
- builder.setMetadata(({ modules }) => {
98
- const metadata = {
99
- fusion: {
100
- spa: {
101
- version,
102
- },
103
- },
104
- // biome-ignore lint/suspicious/noExplicitAny: we need to use any here to allow dynamic properties
105
- } as Record<string, any>;
106
- // Only attach user metadata when the auth module has resolved an account
107
- if (modules?.auth) {
108
- metadata.fusion.user = {
109
- id: modules.auth.account?.homeAccountId,
110
- name: modules.auth.account?.name,
111
- email: modules.auth.account?.username,
112
- };
113
- }
114
- return metadata;
115
- });
116
- },
117
- });
118
-
119
- (async () => {
120
- // initialize the framework - this will create the framework instance and configure the modules
121
- const ref =
122
- await configurator.initialize<
123
- [ServiceDiscoveryModule, HttpModule, MsalModule, TelemetryModule]
124
- >();
125
-
126
- const telemetry = ref.telemetry;
127
-
128
- // attach service discovery to the framework - append auth token to configured endpoints
129
- using measurement = telemetry.measure({
130
- name: 'bootstrap',
131
- level: TelemetryLevel.Information,
132
- });
133
-
134
- await measurement.clone().resolve(registerServiceWorker(ref), {
135
- data: {
136
- level: TelemetryLevel.Debug,
137
- name: 'bootstrap::registerServiceWorker',
138
- },
139
- });
140
-
141
- // create a client for the portal service - this is used to fetch the portal manifest
142
- const portalClient = await ref.serviceDiscovery.createClient('portal-config');
143
-
144
- // fetch the portal manifest - this is used to load the portal template
145
- const portalId = import.meta.env.FUSION_SPA_PORTAL_ID;
146
- const portalTag = import.meta.env.FUSION_SPA_PORTAL_TAG ?? 'latest';
147
- const portalProxy = import.meta.env.FUSION_SPA_PORTAL_PROXY ?? false;
148
- const portal_manifest = await measurement
149
- .clone()
150
- .resolve(portalClient.json<PortalManifest>(`/portals/${portalId}@${portalTag}`), {
151
- data: (manifest: PortalManifest) => ({
152
- name: 'bootstrap::loadPortalManifest',
153
- level: TelemetryLevel.Debug,
154
- properties: {
155
- portalId,
156
- portalTag,
157
- templateEntry: manifest.build.templateEntry,
158
- manifestVersion: manifest.build.config?.version,
159
- assetPath: manifest.build.assetPath,
160
- },
161
- }),
162
- });
163
-
164
- const portal_config = await measurement
165
- .clone()
166
- .resolve(portalClient.json(`/portals/${portalId}@${portalTag}/config`), {
167
- data: {
168
- name: 'bootstrap::loadPortalConfig',
169
- level: TelemetryLevel.Debug,
170
- properties: {
171
- portalId,
172
- portalTag,
173
- },
174
- },
175
- });
176
-
177
- // create a entrypoint for the portal - this is used to render the portal
178
- const el = document.createElement('div');
179
- document.body.innerHTML = '';
180
- document.body.appendChild(el);
181
-
182
- const portalEntryPoint = createPortalEntryPoint(
183
- portalProxy ? '/portal-proxy' : undefined,
184
- portal_manifest.build.assetPath,
185
- portal_manifest.build.templateEntry,
186
- );
187
-
188
- // TODO(#5066): should test if the entrypoint is external or internal
189
- // TODO(#5066): add proper return type
190
- const { render } = await measurement
191
- .clone()
192
- .resolve(
193
- importWithoutVite<Promise<{ render: (...args: unknown[]) => void }>>(portalEntryPoint),
194
- {
195
- data: {
196
- name: 'bootstrap::loadPortalSourceCode',
197
- level: TelemetryLevel.Debug,
198
- properties: {
199
- portalId,
200
- portalTag,
201
- entryPoint: portalEntryPoint,
202
- },
203
- },
204
- },
205
- );
206
-
207
- // render the portal - this will load the portal template and render it
208
- render(el, { ref, manifest: portal_manifest, config: portal_config });
209
- })();
@@ -1,25 +0,0 @@
1
- /**
2
- * Builds the portal template entrypoint URL used by the SPA bootstrap loader.
3
- *
4
- * @param segments - Ordered URL/path segments such as proxy prefix, asset path, and template entry.
5
- * @returns A normalized entrypoint string with single slashes between segments.
6
- */
7
- export const createPortalEntryPoint = (...segments: Array<string | undefined | null>): string => {
8
- const normalized = segments
9
- // Drop undefined/null segments before further processing
10
- .filter((segment): segment is string => segment !== undefined && segment !== null)
11
- // Trim stray whitespace from each segment
12
- .map((segment) => segment.trim())
13
- // Drop any segments that became empty after trimming
14
- .filter(Boolean)
15
- // Strip leading slashes from every segment (and trailing slashes from the first)
16
- .map((segment, index) => {
17
- // The first segment may have a trailing slash (e.g. a base URL) that needs stripping
18
- if (index === 0) {
19
- return segment.replace(/\/+$/g, '');
20
- }
21
- return segment.replace(/^\/+|\/+$/g, '');
22
- });
23
-
24
- return normalized.join('/');
25
- };
package/src/html/html.ts DELETED
@@ -1,55 +0,0 @@
1
- import { version } from '../version.js';
2
-
3
- /**
4
- * Represents an HTML template string used for generating the main structure of an SPA (Single Page Application).
5
- *
6
- * @see {@link https://vite.dev/guide/env-and-mode.html#html-constant-replacement}
7
- *
8
- * The template includes placeholders for dynamic values such as:
9
- * - `%FUSION_SPA_TITLE%`: The title of the SPA.
10
- * - `%MODE%`: The mode of the application (e.g., development, production).
11
- * - `%FUSION_SPA_BOOTSTRAP%`: The path to the bootstrap script for initializing the SPA.
12
- *
13
- * Additionally, it includes:
14
- * - A meta tag for the plugin version, dynamically populated using the `version` variable.
15
- * - A link to the Equinor font stylesheet hosted on a CDN.
16
- *
17
- * @constant
18
- * @type {string}
19
- */
20
- export const html = `
21
- <!DOCTYPE html>
22
- <html>
23
- <head>
24
- <title>%FUSION_SPA_TITLE%</title>
25
- <meta name="mode" content="%MODE%">
26
- <meta name="fusion-spa-plugin-version" content="${version}">
27
- <link rel="stylesheet" href="https://cdn.eds.equinor.com/font/equinor-font.css" />
28
- <script type="module" src="%FUSION_SPA_BOOTSTRAP%"></script>
29
- <script>
30
- // Set AG Grid license key globally if provided
31
- window.FUSION_AG_GRID_KEY = '%FUSION_SPA_AG_GRID_KEY%';
32
-
33
- // suppress console error for custom elements already defined.
34
- // WebComponents should be added by the portal, but not removed from application
35
- const _customElementsDefine = window.customElements.define;
36
- window.customElements.define = (name, cl, conf) => {
37
- if (!customElements.get(name)) {
38
- _customElementsDefine.call(window.customElements, name, cl, conf);
39
- }
40
- };
41
- </script>
42
- <style>
43
- html, body {
44
- margin: 0;
45
- padding: 0;
46
- height: 100%;
47
- font-family: 'EquinorFont', sans-serif;
48
- }
49
- </style>
50
- </head>
51
- <body></body>
52
- </html>
53
- `;
54
-
55
- export default html;
package/src/html/index.ts DELETED
@@ -1,10 +0,0 @@
1
- /**
2
- * Public API surface of the `@equinor/fusion-framework-vite-plugin-spa/html`
3
- * sub-path export.
4
- *
5
- * @remarks
6
- * Re-exports the {@link registerServiceWorker} function so that custom
7
- * bootstrap files can register the SPA service worker without importing
8
- * internal paths.
9
- */
10
- export { registerServiceWorker } from './register-service-worker.js';
@@ -1,9 +0,0 @@
1
- /**
2
- * Resolves a Vite environment toggle that may arrive as a parsed boolean or raw string.
3
- *
4
- * @param value - Environment value supplied through plugin configuration or a custom Vite define.
5
- * @returns `true` only for the boolean `true` or the string `'true'`.
6
- */
7
- export function isEnabledEnvValue(value: unknown): boolean {
8
- return value === true || value === 'true';
9
- }
@@ -1,228 +0,0 @@
1
- import type { ModulesInstance } from '@equinor/fusion-framework-module';
2
- import type { MsalModule } from '@equinor/fusion-framework-module-msal';
3
- import { TelemetryLevel, type TelemetryModule } from '@equinor/fusion-framework-module-telemetry';
4
-
5
- /**
6
- * Registers the Fusion SPA service worker and wires token acquisition.
7
- *
8
- * @remarks
9
- * The service worker intercepts outgoing fetch requests that match
10
- * the configured {@link ResourceConfiguration | resource patterns},
11
- * rewrites URLs, and injects Bearer tokens obtained from the MSAL
12
- * module. This function:
13
- *
14
- * 1. Registers `/@fusion-spa-sw.js` as a module service worker.
15
- * 2. Listens for `GET_TOKEN` messages from the worker and responds
16
- * with MSAL access tokens.
17
- * 3. Sends the `INIT_CONFIG` message containing resource configurations
18
- * to the active worker once it is ready and controlling the page.
19
- *
20
- * @param framework - An initialized Fusion Framework instance that
21
- * includes the {@link MsalModule} (for token acquisition) and
22
- * {@link TelemetryModule} (for structured logging).
23
- * @throws {Error} When service workers are not supported by the browser.
24
- * @throws {Error} When the `FUSION_SPA_SERVICE_WORKER_RESOURCES`
25
- * environment variable is not defined.
26
- *
27
- * @example
28
- * ```ts
29
- * import { registerServiceWorker } from '@equinor/fusion-framework-vite-plugin-spa/html';
30
- *
31
- * const framework = await configurator.initialize();
32
- * await registerServiceWorker(framework);
33
- * ```
34
- */
35
- export async function registerServiceWorker(
36
- framework: ModulesInstance<[MsalModule, TelemetryModule]>,
37
- ) {
38
- const telemetry = framework.telemetry;
39
- // Bail out early when the browser has no service worker support at all
40
- if ('serviceWorker' in navigator === false) {
41
- const exception = new Error('Service workers are not supported in this browser.');
42
- exception.name = 'ServiceWorkerNotSupported';
43
- telemetry.trackException({
44
- name: `registerServiceWorker.${exception.name}`,
45
- exception,
46
- });
47
- throw exception;
48
- }
49
-
50
- const resourceConfigs = import.meta.env.FUSION_SPA_SERVICE_WORKER_RESOURCES;
51
- // The worker needs a resource config to know which requests to intercept
52
- if (!resourceConfigs) {
53
- const exception = new Error('Service worker config is not defined.');
54
- exception.name = 'ServiceWorkerConfigNotDefined';
55
- telemetry.trackException({
56
- name: `registerServiceWorker.${exception.name}`,
57
- exception,
58
- });
59
- throw exception;
60
- }
61
-
62
- /**
63
- * Helper function to send configuration to the service worker
64
- */
65
- const sendConfigToServiceWorker = (worker: ServiceWorker) => {
66
- worker.postMessage({
67
- type: 'INIT_CONFIG',
68
- config: resourceConfigs,
69
- });
70
- };
71
-
72
- try {
73
- // allow the service worker to start receiving messages early
74
- navigator.serviceWorker.startMessages();
75
-
76
- // listen for messages from the service worker (set up before registration)
77
- navigator.serviceWorker.addEventListener('message', async (event) => {
78
- // Only the GET_TOKEN message type requests a token from this handler
79
- if (event.data.type === 'GET_TOKEN') {
80
- try {
81
- // extract scopes from the event data
82
- const scopes = event.data.scopes as string[];
83
- // Scopes must be a real array before they can be used to request a token
84
- if (!scopes || !Array.isArray(scopes)) {
85
- const error = new Error('Invalid scopes provided');
86
- error.name = 'InvalidScopesProvided';
87
- throw error;
88
- }
89
-
90
- // request a token from the MSAL module
91
- const token = await framework.auth.acquireToken({ request: { scopes } });
92
-
93
- // A missing token means acquisition failed and the worker can't proceed
94
- if (!token) {
95
- const error = new Error('Failed to acquire token');
96
- error.name = 'FailedToAcquireToken';
97
-
98
- throw error;
99
- }
100
-
101
- // send the token back to the service worker
102
- event.ports[0].postMessage({
103
- accessToken: token.accessToken,
104
- expiresOn: token.expiresOn?.getTime(),
105
- });
106
- } catch (error) {
107
- const exception = error as Error;
108
- telemetry.trackException({
109
- name: `serviceWorker.onMessage.${exception.name}`,
110
- exception,
111
- });
112
- event.ports[0].postMessage({
113
- error: (error as Error).message,
114
- });
115
- }
116
- }
117
- });
118
-
119
- // register the service worker with telemetry
120
- // updateViaCache: 'none' ensures the service worker script is always fetched fresh
121
- // This is important during development to pick up code changes
122
- using measurement = telemetry.measure({
123
- name: 'registerServiceWorker',
124
- level: TelemetryLevel.Information,
125
- });
126
- const registration = await measurement.clone().resolve(
127
- navigator.serviceWorker.register('/@fusion-spa-sw.js', {
128
- type: 'module',
129
- scope: '/',
130
- updateViaCache: 'none',
131
- }),
132
- {
133
- data: {
134
- name: 'registerServiceWorker.register',
135
- level: TelemetryLevel.Debug,
136
- },
137
- },
138
- );
139
-
140
- // Handle service worker updates/installations
141
- // If there's a service worker waiting or installing, send config when it activates
142
- if (registration.waiting) {
143
- sendConfigToServiceWorker(registration.waiting);
144
- }
145
- // A worker still installing needs to reach the 'activated' state before it can receive config
146
- if (registration.installing) {
147
- registration.installing.addEventListener('statechange', (event) => {
148
- const worker = event.target as ServiceWorker;
149
- // Only send config once the worker has fully activated
150
- if (worker.state === 'activated') {
151
- sendConfigToServiceWorker(worker);
152
- }
153
- });
154
- }
155
-
156
- // Listen for controller changes (happens during hard refresh or updates)
157
- navigator.serviceWorker.addEventListener('controllerchange', () => {
158
- // Only send config once this page is actually controlled by a worker
159
- if (navigator.serviceWorker.controller) {
160
- sendConfigToServiceWorker(navigator.serviceWorker.controller);
161
- }
162
- });
163
-
164
- // wait for the service worker to be ready
165
- const readyRegistration = await measurement.clone().resolve(navigator.serviceWorker.ready, {
166
- data: {
167
- name: 'registerServiceWorker.ready',
168
- level: TelemetryLevel.Debug,
169
- },
170
- });
171
-
172
- // ensure we have an active service worker before sending config
173
- const activeWorker = readyRegistration.active;
174
- // Without an active worker there's nothing to send config to
175
- if (!activeWorker) {
176
- console.error('[Service Worker Registration] Service worker is not active after ready state');
177
- return;
178
- }
179
-
180
- // CRITICAL: Wait for the service worker to become the controller
181
- // This ensures the service worker can intercept fetch requests
182
- if (!navigator.serviceWorker.controller) {
183
- await measurement.clone().resolve(
184
- new Promise<void>((resolve) => {
185
- let checkInterval: NodeJS.Timeout;
186
-
187
- const finish = () => {
188
- clearInterval(checkInterval);
189
- navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
190
- resolve();
191
- };
192
-
193
- const onControllerChange = () => finish();
194
-
195
- // If controllerchange fires, the service worker has taken control
196
- navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
197
-
198
- // Polling fallback and timeout to prevent infinite waiting
199
- checkInterval = setInterval(() => {
200
- // Stop polling once a controller has taken over
201
- if (navigator.serviceWorker.controller) finish();
202
- }, 200);
203
-
204
- setTimeout(finish, 5000);
205
- }),
206
- {
207
- data: {
208
- name: 'registerServiceWorker.controllerWait',
209
- level: TelemetryLevel.Debug,
210
- },
211
- },
212
- );
213
- }
214
-
215
- // send the config to the active service worker
216
- sendConfigToServiceWorker(activeWorker);
217
-
218
- // Also send to the controller if it exists and is different from active
219
- if (navigator.serviceWorker.controller && navigator.serviceWorker.controller !== activeWorker) {
220
- sendConfigToServiceWorker(navigator.serviceWorker.controller);
221
- }
222
- } catch (error) {
223
- telemetry.trackException({
224
- name: `registerServiceWorker.${(error as Error).name}`,
225
- exception: error as Error,
226
- });
227
- }
228
- }