@equinor/fusion-framework-vite-plugin-spa 1.0.0-next.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,82 @@
1
+ # @equinor/fusion-framework-vite-plugin-spa
2
+
3
+ ## 1.0.0-next.0
4
+
5
+ ### Major Changes
6
+
7
+ - [#3074](https://github.com/equinor/fusion-framework/pull/3074) [`6b034e5`](https://github.com/equinor/fusion-framework/commit/6b034e5459094cea0c0f2490335eef3092390a13) Thanks [@odinr](https://github.com/odinr)! - ---
8
+
9
+ **New Package: Fusion Framework Vite SPA Plugin**
10
+
11
+ This plugin enables building single-page applications (SPAs) with Vite. It provides features such as service discovery, MSAL authentication, and service worker configuration.
12
+
13
+ **Features**:
14
+
15
+ - **Service Discovery**: Fetch service discovery configurations and authenticate requests.
16
+ - **MSAL Authentication**: Authenticate users with Azure AD.
17
+ - **Service Worker**: Intercept fetch requests, apply authentication headers, and rewrite URLs.
18
+ - **Custom Templates**: Define custom HTML templates for SPAs.
19
+ - **Environment Configuration**: Configure the plugin using `.env` files or programmatically.
20
+
21
+ **Usage**:
22
+
23
+ ```ts
24
+ import { defineConfig } from "vite";
25
+ import { plugin as fusionSpaPlugin } from "@equinor/fusion-framework-vite-plugin-spa";
26
+
27
+ export default defineConfig({
28
+ plugins: [fusionSpaPlugin()],
29
+ });
30
+ ```
31
+
32
+ ```ts
33
+ fusionSpaPlugin({
34
+ generateTemplateEnv: () => ({
35
+ title: "My App",
36
+ portal: {
37
+ id: "my-portal",
38
+ },
39
+ serviceDiscovery: {
40
+ url: "https://my-server.com/service-discovery",
41
+ scopes: ["api://my-app/scope"],
42
+ },
43
+ msal: {
44
+ tenantId: "my-tenant-id",
45
+ clientId: "my-client-id",
46
+ redirectUri: "https://my-app.com/auth-callback",
47
+ requiresAuth: "true",
48
+ },
49
+ serviceWorker: {
50
+ resources: [
51
+ {
52
+ url: "/app-proxy",
53
+ rewrite: "/@fusion-api/app",
54
+ scopes: ["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.default"],
55
+ },
56
+ ],
57
+ },
58
+ }),
59
+ });
60
+ ```
61
+
62
+ **Additional Details**:
63
+
64
+ - **Custom Bootstrap**: Allows defining custom bootloader scripts.
65
+ - **Dynamic Proxy**: Supports dynamic proxy services using `@equinor/fusion-framework-vite-plugin-api-service`.
66
+ - **Environment Variables**: Automatically maps `.env` variables to `import.meta.env`.
67
+
68
+ Refer to the README for detailed documentation.
69
+
70
+ ### Patch Changes
71
+
72
+ - [#3074](https://github.com/equinor/fusion-framework/pull/3074) [`6b034e5`](https://github.com/equinor/fusion-framework/commit/6b034e5459094cea0c0f2490335eef3092390a13) Thanks [@odinr](https://github.com/odinr)! - Fetch and pass portal config to portal render function in bootstrap.ts
73
+
74
+ - The SPA bootstrap script now fetches the portal config from `/portals/{portalId}@{portalTag}/config` and passes it as `config` to the portal's render function.
75
+ - This enables portals to receive their runtime configuration directly at startup.
76
+
77
+ - [#3074](https://github.com/equinor/fusion-framework/pull/3074) [`6b034e5`](https://github.com/equinor/fusion-framework/commit/6b034e5459094cea0c0f2490335eef3092390a13) Thanks [@odinr](https://github.com/odinr)! - update Vite to 6.3.5
78
+
79
+ - Updated dependencies [[`6b034e5`](https://github.com/equinor/fusion-framework/commit/6b034e5459094cea0c0f2490335eef3092390a13), [`6b034e5`](https://github.com/equinor/fusion-framework/commit/6b034e5459094cea0c0f2490335eef3092390a13), [`6b034e5`](https://github.com/equinor/fusion-framework/commit/6b034e5459094cea0c0f2490335eef3092390a13)]:
80
+ - @equinor/fusion-framework-vite-plugin-api-service@1.0.0-next.0
81
+ - @equinor/fusion-framework-module-http@6.3.3-next.0
82
+ - @equinor/fusion-framework-module-service-discovery@8.0.15-next.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Equinor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,211 @@
1
+ # Fusion Framework Vite SPA Plugin
2
+
3
+ This plugin is used to build a single page application (SPA) with Vite.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { defineConfig } from 'vite';
9
+ import { plugin as fusionSpaPlugin } from '@equinor/fusion-framework-vite-plugin-spa';
10
+
11
+ export default defineConfig({
12
+ plugins: [fusionSpaPlugin()],
13
+ });
14
+ ```
15
+
16
+ ## Configure the plugin
17
+
18
+ ```ts
19
+ fusionSpaPlugin({
20
+ generateTemplateEnv: () => {
21
+ return {
22
+ title: 'My App',
23
+ portal: {
24
+ id: 'my-portal',
25
+ },
26
+ serviceDiscovery: {
27
+ url: 'https://my-server.com/service-discovery',
28
+ scopes: ['api://my-app/scope'],
29
+ },
30
+ msal: {
31
+ tenantId: 'my-tenant-id',
32
+ clientId: 'my-client-id',
33
+ redirectUri: 'https://my-app.com/auth-callback',
34
+ requiresAuth: 'true',
35
+ },
36
+ serviceWorker: {
37
+ resources: [
38
+ {
39
+ url: '/app-proxy',
40
+ rewrite: '/@fusion-api/app',
41
+ scopes: ['xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.default'],
42
+ ],
43
+ },
44
+ }
45
+ }
46
+ });
47
+ ```
48
+
49
+ ### Service Discovery
50
+
51
+ The service discovery URL is used to fetch the service discovery configuration from the server.
52
+ The service discovery scopes are used to authenticate the service discovery request.
53
+
54
+ ### MSAL
55
+
56
+ The MSAL configuration is used to authenticate the user with Azure AD.
57
+ - `tenantId` is the ID of the Azure AD tenant.
58
+ - `clientId` is the ID of the Azure AD application.
59
+ - `redirectUri` is the URL to redirect the user to after authentication.
60
+ - `requiresAuth` _(optional)_ property is used to specify if the framework requires authentication, will try to login user on initial load.
61
+
62
+ ### Service Worker
63
+
64
+ The service worker configuration is used to configure routes for the service worker.
65
+
66
+ When `fetch` calls are made to the `url` path, the service worker will intercept the request and apply authentication headers to the request for the specified scopes. The request url will be rewritten to the `rewrite` path, by replacing the `url` with the `rewrite` path.
67
+
68
+ - `resources` is an array of resources which the service worker will handle.
69
+ - `url` path to match requests to
70
+ - `rewrite` _(optional)_ path to replace the `url` with
71
+ - `scopes` _(optional)_ is an array of scopes to use for the resource.
72
+
73
+ Example:
74
+ ```ts
75
+ const serviceWorker = {
76
+ resources: [
77
+ {
78
+ url: '/app-proxy',
79
+ rewrite: '/@fusion-api/app',
80
+ scopes: [
81
+ '2bed749c-843b-413d-8b17-e7841869730f/.default',
82
+ '8c24cf81-de7a-435b-ab74-e90b1a7bda0a/.default'
83
+ ],
84
+ },
85
+ ],
86
+ };
87
+
88
+ fetch('/app-proxy/assets/some-app/resource-path/resource.json');
89
+ ```
90
+ 1. The request will be intercepted by the service worker.
91
+ 2. The service worker will post message to the main thread for authentication.
92
+ 3. The main thread will generate the authentication token for the specified scopes.
93
+ 4. The service worker will rewrite the request to `/@fusion-api/app/assets/some-app/resource-path/resource.json`.
94
+ 5. The service worker will execute the request to the rewritten path with the authentication token.
95
+
96
+ > [!TIP]
97
+ > The `Url` does not have an endpoint, it is just a path to match the request to,
98
+ > so that url can emulate a proxy service in the production environment.
99
+ > The `rewrite` path is the actual endpoint to call.
100
+
101
+ > [!TIP]
102
+ > Using the `@equinor/fusion-framework-vite-plugin-api-service` plugin,
103
+ > you can create a dynamic proxy service that will handle the request.
104
+ >
105
+ > The `/@fusion-api/app` points to proxy route which can be intercepted in the dev-server.
106
+ > This endpoint is generated from the service discovery configuration and will dynamically proxy the request to the service discovery endpoint.
107
+
108
+ ## Configuring through `.env` file
109
+
110
+ The plugin can be configured through a `.env` file. The plugin will read the `.env` file and override the properties in the `generateTemplateEnv` function.
111
+
112
+ The property names are prefixed with `FUSION_SPA_` and snake cased.
113
+
114
+ example:
115
+
116
+ ```ts
117
+ {
118
+ serviceWorker: {
119
+ resources: [...],
120
+ },
121
+ }
122
+ // will be converted to
123
+ FUSION_SPA_SERVICE_WORKER_RESOURCES: [...],
124
+
125
+ // and accessible in the template as
126
+ import.meta.env.FUSION_SPA_SERVICE_WORKER_RESOURCES
127
+ ```
128
+
129
+ > [!TIP]
130
+ > Prefer to use the `generateTemplateEnv` function to provide the properties.
131
+ > The `.env` file is used to override configurations, example in an CI/CD pipeline.
132
+
133
+ > [!IMPORTANT]
134
+ > The `.env` file should be placed in the root of the project.
135
+ > Parameters from the `.env` file overrides the parameters from the `generateTemplateEnv` function.
136
+
137
+ ```
138
+ FUSION_SPA_TITLE=My App
139
+ FUSION_SPA_PORTAL_ID=my-portal
140
+ FUSION_SPA_SERVICE_DISCOVERY_URL=https://my-server.com/service-discovery
141
+ FUSION_SPA_SERVICE_DISCOVERY_SCOPES=[api://my-app/scope]
142
+ FUSION_SPA_MSAL_TENANT_ID=my-tenant-id
143
+ FUSION_SPA_MSAL_CLIENT_ID=my-client-id
144
+ FUSION_SPA_MSAL_REDIRECT_URI=https://my-app.com/auth-callback
145
+ FUSION_SPA_MSAL_REQUIRES_AUTH=true
146
+ FUSION_SPA_SERVICE_WORKER_RESOURCES=[{"url":"/app-proxy","rewrite":"/@fusion-api/app","scopes":["xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.default"]}]
147
+ ```
148
+
149
+ ## Providing a custom template
150
+
151
+ > [!WARNING]
152
+ > Your almost walking on the edge of the framework. Be careful.
153
+ > At this point you might just define your own template and the plugin will inject the properties into the template.
154
+
155
+ ```ts
156
+ const template = `
157
+ <!DOCTYPE html>
158
+ <html lang="en">
159
+ <head>
160
+ <script type="module" src="./src/my-custom-bootloader"></script>
161
+ </head>
162
+ <body>
163
+ <h1>%MY_CUSTOM_PROPERTY%</h1>
164
+ <div id="app"></div>
165
+ </body>
166
+ </html>
167
+ `;
168
+
169
+ import { defineConfig } from 'vite';
170
+ import { createViteSPAPlugin } from '@equinor/fusion-framework-vite-plugin-spa';
171
+
172
+ const templateEnvPrefix = 'MY_CUSTOM_';
173
+
174
+ export default defineConfig({
175
+ define: {
176
+ `import.meta.env.${templateEnvPrefix}PROPERTY`: 'my-custom-value',
177
+ },
178
+ plugins: [createViteSPAPlugin({template, templateEnvPrefix})],
179
+ });
180
+ ```
181
+
182
+ > [!TIP]
183
+ > see [Vite documentation](https://vite.dev/guide/env-and-mode.html#html-constant-replacement) for more information about customizing the template.
184
+
185
+ ### Providing custom bootstrap
186
+
187
+ ```ts
188
+ fusionSpaPlugin({
189
+ generateTemplateEnv: () => {
190
+ return {
191
+ bootstrap: 'src/my-custom-bootloader.ts',
192
+ }
193
+ }
194
+ });
195
+ ```
196
+
197
+ > [!WARNING]
198
+ > The bootstrapping of the `ServiceWorker` is done in the the bootloader,
199
+ > this functionality will no longer be available, but needs to be re-implemented in the custom bootloader.
200
+
201
+
202
+ ```ts
203
+ // custom-bootloader.ts
204
+ import { registerServiceWorker} from '@equinor/fusion-framework-vite-plugin-spa/html';
205
+
206
+ import initializeFramework from './my-custom-framework.js';
207
+
208
+ registerServiceWorker(await initializeFramework());
209
+ ```
210
+
211
+
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@equinor/fusion-framework-vite-plugin-spa",
3
+ "version": "1.0.0-next.0",
4
+ "description": "Vite plugin for SPA development",
5
+ "type": "module",
6
+ "types": "dist/types/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/esm/index.js",
10
+ "types": "./dist/types/index.d.ts"
11
+ },
12
+ "./html": {
13
+ "import": "./dist/esm/html/index.js",
14
+ "types": "./dist/types/html/index.d.ts"
15
+ }
16
+ },
17
+ "directories": {
18
+ "dist": "dist"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/equinor/fusion-framework.git",
23
+ "directory": "packages/vite-plugins/spa"
24
+ },
25
+ "keywords": [
26
+ "vite",
27
+ "fusion-framework",
28
+ "dev-server",
29
+ "typescript"
30
+ ],
31
+ "license": "ISC",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "lodash.mergewith": "^4.6.2",
37
+ "@equinor/fusion-framework-module": "^4.4.2",
38
+ "@equinor/fusion-framework-module-http": "^6.3.3-next.0",
39
+ "@equinor/fusion-framework-module-msal": "^4.0.6",
40
+ "@equinor/fusion-framework-module-service-discovery": "^8.0.15-next.0",
41
+ "@equinor/fusion-framework-vite-plugin-api-service": "^1.0.0-next.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/lodash.mergewith": "^4.6.9",
45
+ "typescript": "^5.5.4",
46
+ "vite": "^6.3.5"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc -b",
50
+ "test": "vitest"
51
+ }
52
+ }
@@ -0,0 +1,90 @@
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
+ import { registerServiceWorker } from './register-service-worker.js';
10
+
11
+ // @todo - add type for portal manifest when available
12
+ type PortalManifest = {
13
+ build: {
14
+ config: Record<string, unknown>;
15
+ entrypoint: string;
16
+ };
17
+ };
18
+
19
+ // Allow dynamic import without vite
20
+ const importWithoutVite = <T>(path: string): Promise<T> => import(/* @vite-ignore */ path);
21
+
22
+ // Create Fusion Framework configurator
23
+ const configurator = new ModulesConfigurator();
24
+
25
+ configurator.logger.level = import.meta.env.FUSION_SPA_LOG_LEVEL ?? 1;
26
+
27
+ const serviceDiscoveryUrl = new URL(
28
+ import.meta.env.FUSION_SPA_SERVICE_DISCOVERY_URL,
29
+ import.meta.env.FUSION_SPA_SERVICE_DISCOVERY_URL.startsWith('http')
30
+ ? undefined
31
+ : window.location.origin,
32
+ );
33
+
34
+ // define service discovery client - this is used in the service discovery module
35
+ configurator.addConfig(
36
+ configureHttpClient('service_discovery', {
37
+ baseUri: String(serviceDiscoveryUrl),
38
+ defaultScopes: import.meta.env.FUSION_SPA_SERVICE_DISCOVERY_SCOPES,
39
+ }),
40
+ );
41
+
42
+ // setup service discovery - enable service discovery for the framework
43
+ enableServiceDiscovery(configurator, async (builder) => {
44
+ builder.configureServiceDiscoveryClientByClientKey('service_discovery');
45
+ });
46
+
47
+ // setup authentication
48
+ enableMSAL(configurator, (builder) => {
49
+ builder.setClientConfig({
50
+ tenantId: import.meta.env.FUSION_SPA_MSAL_TENANT_ID,
51
+ clientId: import.meta.env.FUSION_SPA_MSAL_CLIENT_ID,
52
+ redirectUri: import.meta.env.FUSION_SPA_MSAL_REDIRECT_URI,
53
+ });
54
+
55
+ builder.setRequiresAuth(Boolean(import.meta.env.FUSION_SPA_MSAL_REQUIRES_AUTH));
56
+ });
57
+
58
+ (async () => {
59
+ // initialize the framework - this will create the framework instance and configure the modules
60
+ const ref = await configurator.initialize<[ServiceDiscoveryModule, HttpModule, MsalModule]>();
61
+
62
+ // attach service discovery to the framework - append auth token to configured endpoints
63
+ await registerServiceWorker(ref);
64
+
65
+ // create a client for the portal service - this is used to fetch the portal manifest
66
+ const portalClient = await ref.serviceDiscovery.createClient('portals');
67
+
68
+ // fetch the portal manifest - this is used to load the portal template
69
+ const portalId = import.meta.env.FUSION_SPA_PORTAL_ID;
70
+ const portalTag = import.meta.env.FUSION_SPA_PORTAL_TAG ?? 'latest';
71
+ const portal_manifest = await portalClient.json<PortalManifest>(
72
+ `/portals/${portalId}@${portalTag}`,
73
+ );
74
+
75
+ const portal_config = await portalClient.json(`/portals/${portalId}@${portalTag}/config`);
76
+
77
+ // create a entrypoint for the portal - this is used to render the portal
78
+ const el = document.createElement('div');
79
+ document.body.innerHTML = '';
80
+ document.body.appendChild(el);
81
+
82
+ // @todo: should test if the entrypoint is external or internal
83
+ // @todo: add proper return type
84
+ const { render } = await importWithoutVite<Promise<{ render: (...args: unknown[]) => void }>>(
85
+ portal_manifest.build.entrypoint,
86
+ );
87
+
88
+ // render the portal - this will load the portal template and render it
89
+ render(el, { ref, manifest: portal_manifest, config: portal_config });
90
+ })();
@@ -0,0 +1,52 @@
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
+ // suppress console error for custom elements already defined.
31
+ // WebComponents should be added by the portal, but not removed from application
32
+ const _customElementsDefine = window.customElements.define;
33
+ window.customElements.define = (name, cl, conf) => {
34
+ if (!customElements.get(name)) {
35
+ _customElementsDefine.call(window.customElements, name, cl, conf);
36
+ }
37
+ };
38
+ </script>
39
+ <style>
40
+ html, body {
41
+ margin: 0;
42
+ padding: 0;
43
+ height: 100%;
44
+ font-family: 'EquinorFont', sans-serif;
45
+ }
46
+ </style>
47
+ </head>
48
+ <body></body>
49
+ </html>
50
+ `;
51
+
52
+ export default html;
@@ -0,0 +1 @@
1
+ export { registerServiceWorker } from './register-service-worker.js';
@@ -0,0 +1,67 @@
1
+ import type { ModulesInstance } from '@equinor/fusion-framework-module';
2
+ import type { MsalModule } from '@equinor/fusion-framework-module-msal';
3
+
4
+ export async function registerServiceWorker(framework: ModulesInstance<[MsalModule]>) {
5
+ if ('serviceWorker' in navigator === false) {
6
+ console.warn('Service workers are not supported in this browser.');
7
+ return;
8
+ }
9
+
10
+ const resourceConfigs = import.meta.env.FUSION_SPA_SERVICE_WORKER_RESOURCES;
11
+ if (!resourceConfigs) {
12
+ console.warn('Service worker config is not defined.');
13
+ return;
14
+ }
15
+
16
+ try {
17
+ // register the service worker
18
+ const registration = await navigator.serviceWorker.register('/@fusion-spa-sw.js', {
19
+ type: 'module',
20
+ scope: '/',
21
+ });
22
+
23
+ // wait for the service worker to be ready
24
+ await navigator.serviceWorker.ready;
25
+
26
+ // allow the service worker to start receiving messages
27
+ navigator.serviceWorker.startMessages();
28
+
29
+ // send the config to the service worker
30
+ registration.active?.postMessage({
31
+ type: 'INIT_CONFIG',
32
+ config: resourceConfigs,
33
+ });
34
+
35
+ // listen for messages from the service worker
36
+ navigator.serviceWorker.addEventListener('message', async (event) => {
37
+ if (event.data.type === 'GET_TOKEN') {
38
+ try {
39
+ // extract scopes from the event data
40
+ const scopes = event.data.scopes as string[];
41
+ if (!scopes || !Array.isArray(scopes)) {
42
+ throw new Error('Invalid scopes provided');
43
+ }
44
+
45
+ // request a token from the MSAL module
46
+ const token = await framework.auth.acquireToken({ scopes });
47
+
48
+ if (!token) {
49
+ throw new Error('Failed to acquire token');
50
+ }
51
+
52
+ // send the token back to the service worker
53
+ event.ports[0].postMessage({
54
+ accessToken: token.accessToken,
55
+ expiresOn: token.expiresOn?.getTime(),
56
+ });
57
+ } catch (error) {
58
+ event.ports[0].postMessage({
59
+ error: (error as Error).message,
60
+ });
61
+ }
62
+ }
63
+ });
64
+ } catch (error) {
65
+ console.error('Service Worker registration failed:', error);
66
+ }
67
+ }
package/src/html/sw.ts ADDED
@@ -0,0 +1,211 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import type { ResourceConfiguration } from '../types.js';
4
+
5
+ /**
6
+ * Represents an authentication token with an access token and its expiration time.
7
+ */
8
+ type Token = {
9
+ accessToken: string;
10
+ expiresOn: number;
11
+ };
12
+
13
+ /**
14
+ * A cache structure for storing tokens, where each token is associated with a unique string key.
15
+ *
16
+ * @typeParam string - The key used to identify a token in the cache.
17
+ * @typeParam Token - The type of the token being stored in the cache.
18
+ */
19
+ type TokenCache = Map<string, Token>;
20
+
21
+ /**
22
+ * A reference to the global scope of the service worker.
23
+ *
24
+ * The `self` variable is explicitly cast to `ServiceWorkerGlobalScope` to ensure
25
+ * type safety and provide access to service worker-specific APIs.
26
+ *
27
+ * This is necessary because `globalThis` is a generic global object and does not
28
+ * include service worker-specific properties and methods by default.
29
+ */
30
+ const self = globalThis as unknown as ServiceWorkerGlobalScope;
31
+
32
+ /**
33
+ * An array of settings used for token injection.
34
+ * Each setting defines the configuration for injecting tokens
35
+ * into the application, such as authentication or API tokens.
36
+ */
37
+ let resourceConfigurations: ResourceConfiguration[] = [];
38
+
39
+ /**
40
+ * A cache for storing tokens, implemented as a `Map`.
41
+ * This cache is used to temporarily hold tokens for quick retrieval.
42
+ *
43
+ * @type {TokenCache} - A `Map` instance where the keys and values are determined by the `TokenCache` type definition.
44
+ */
45
+ const tokenCache: TokenCache = new Map();
46
+
47
+ /**
48
+ * Generates a unique key by sorting and concatenating an array of scope strings.
49
+ *
50
+ * @param scopes - An array of strings representing the scopes to be processed.
51
+ * @returns A single string representing the sorted and concatenated scopes, separated by commas.
52
+ */
53
+ function getScopeKey(scopes: string[]): string {
54
+ return scopes.sort().join(',');
55
+ }
56
+
57
+ /**
58
+ * Checks if a token associated with the specified scopes is valid.
59
+ *
60
+ * This function determines the validity of a token by checking if it exists
61
+ * in the token cache and if its expiration time has not been reached.
62
+ *
63
+ * @param scopes - An array of strings representing the scopes for which the token is required.
64
+ * @returns `true` if a valid token exists for the given scopes; otherwise, `false`.
65
+ */
66
+ function isTokenValid(scopes: string[]): boolean {
67
+ const scopeKey = getScopeKey(scopes);
68
+ if (!tokenCache.has(scopeKey)) {
69
+ return false;
70
+ }
71
+ const tokenData = tokenCache.get(scopeKey);
72
+ return tokenData !== undefined && Date.now() < tokenData.expiresOn;
73
+ }
74
+
75
+ /**
76
+ * Requests an access token from a client using the Service Worker's `clients` API.
77
+ * Communicates with the client via a `MessageChannel` to retrieve the token.
78
+ *
79
+ * @param scopes - An array of strings representing the scopes for which the token is requested.
80
+ * @returns A promise that resolves to the token object containing the `accessToken` and `expiresOn` properties.
81
+ * @throws An error if no clients are available or if the client responds with an error.
82
+ *
83
+ * @example
84
+ * ```typescript
85
+ * const token = await requestTokenFromClient(['scope1', 'scope2']);
86
+ * console.log(token.accessToken); // Access token string
87
+ * console.log(token.expiresOn); // Expiration timestamp
88
+ * ```
89
+ */
90
+ async function requestTokenFromClient(scopes: string[]): Promise<Token> {
91
+ const clients = await self.clients.matchAll();
92
+
93
+ // ensure there are clients available
94
+ if (clients.length === 0) {
95
+ throw new Error('No clients available');
96
+ }
97
+
98
+ // create a message channel to communicate with the client
99
+ const messageChannel = new MessageChannel();
100
+ const token = await new Promise<Token>((resolve, reject) => {
101
+ messageChannel.port1.onmessage = (event) => {
102
+ if (event.data.error) {
103
+ reject(event.data.error);
104
+ }
105
+ resolve(event.data as { accessToken: string; expiresOn: number });
106
+ };
107
+ clients[0].postMessage({ type: 'GET_TOKEN', scopes }, [messageChannel.port2]);
108
+ });
109
+
110
+ if (!token) {
111
+ throw new Error('No token received');
112
+ }
113
+
114
+ // store the token in the cache
115
+ tokenCache.set(getScopeKey(scopes), token);
116
+
117
+ return token;
118
+ }
119
+
120
+ /**
121
+ * Retrieves an access token for the specified scopes. If no valid token is found,
122
+ * it requests a new one from the client.
123
+ *
124
+ * @param scopes - An array of strings representing the required scopes for the token.
125
+ * @returns A promise that resolves to the access token as a string.
126
+ * @throws An error if no access token is found after attempting to retrieve or request one.
127
+ */
128
+ async function getToken(scopes: string[]): Promise<string> {
129
+ // if no valid token is found, request a new one
130
+ if (!isTokenValid(scopes)) {
131
+ await requestTokenFromClient(scopes);
132
+ }
133
+ const scopeKey = getScopeKey(scopes);
134
+ const { accessToken } = tokenCache.get(scopeKey) || {};
135
+ if (!accessToken) {
136
+ throw new Error('No access token found');
137
+ }
138
+ return accessToken;
139
+ }
140
+
141
+ // Match request to proxy config
142
+ /**
143
+ * Retrieves the matching token injection configuration for a given URL.
144
+ *
145
+ * @param url - The URL to match against the token injection settings.
146
+ * @returns The matching `TokenInjectionSetting` if found, otherwise `undefined`.
147
+ *
148
+ * The function compares the provided URL with the `url` property of each
149
+ * `TokenInjectionSetting` in the `tokenInjectionSettings` array. If the
150
+ * provided URL starts with the resolved `config.url`, it is considered a match.
151
+ *
152
+ * Note:
153
+ * - If `config.url` starts with a `/`, it is resolved relative to the service
154
+ * worker's origin (`self.location.origin`).
155
+ * - The comparison is performed using fully resolved absolute URLs.
156
+ */
157
+ function getMatchingConfig(url: string): ResourceConfiguration | undefined {
158
+ return resourceConfigurations.find((config) => {
159
+ const configUrl = new URL(
160
+ config.url,
161
+ config.url.startsWith('/') ? self.location.origin : undefined,
162
+ ).href;
163
+ const requestUrl = new URL(url, self.location.origin).href;
164
+ return requestUrl.startsWith(configUrl);
165
+ });
166
+ }
167
+
168
+ // Install event
169
+ self.addEventListener('install', (event: ExtendableEvent) => {
170
+ event.waitUntil(self.skipWaiting());
171
+ });
172
+
173
+ // Activate event
174
+ self.addEventListener('activate', (event: ExtendableEvent) => {
175
+ event.waitUntil(self.clients.claim());
176
+ });
177
+
178
+ // Handle configuration from main thread
179
+ self.addEventListener('message', (event: ExtendableMessageEvent) => {
180
+ const { type, config } = event.data;
181
+ if (type === 'INIT_CONFIG') {
182
+ resourceConfigurations = config as ResourceConfiguration[];
183
+ }
184
+ });
185
+
186
+ // Handle fetch events
187
+ self.addEventListener('fetch', (event: FetchEvent) => {
188
+ const url = new URL(event.request.url);
189
+ const matchedConfig = getMatchingConfig(url.toString());
190
+
191
+ // only handle requests that match the config
192
+ if (matchedConfig) {
193
+ const requestHeaders = new Headers(event.request.headers);
194
+ const handleRequest = async () => {
195
+ // if the matched config has scopes, append the token to the request
196
+ if (matchedConfig.scopes) {
197
+ const token = await getToken(matchedConfig.scopes);
198
+ requestHeaders.set('Authorization', `Bearer ${token}`);
199
+ }
200
+
201
+ // if the matched config has a rewrite, rewrite the url
202
+ if (typeof matchedConfig.rewrite === 'string') {
203
+ url.pathname = url.pathname.replace(matchedConfig?.url, matchedConfig.rewrite);
204
+ }
205
+
206
+ // fetch the request with the modified url and headers
207
+ return fetch(url, { headers: requestHeaders });
208
+ };
209
+ event.respondWith(handleRequest());
210
+ }
211
+ });
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default, plugin as fusionSpaPlugin, type PluginOptions } from './plugin.js';
2
+
3
+ export * from './types.js';
package/src/plugin.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ import mergeWith from 'lodash.mergewith';
4
+
5
+ import defaultTemplate from './html/index.html.js';
6
+
7
+ import { objectToEnv } from './util/object-to-env.js';
8
+ import { loadEnvironment } from './util/load-env.js';
9
+
10
+ import type { TemplateEnv, TemplateEnvFn } from './types.js';
11
+
12
+ /**
13
+ * Represents the options for configuring a plugin.
14
+ *
15
+ * @template TEnv - The type of the template environment. Defaults to `TemplateEnv`.
16
+ *
17
+ * @property {string} [template] - The path to the template file to be used.
18
+ * @property {string} [templateEnvPrefix] - A prefix to filter environment variables for the template.
19
+ * @property generateTemplateEnv -
20
+ * A function to generate the template environment. It receives the configuration environment and
21
+ * a partial template environment as arguments, and returns a modified or extended partial template environment.
22
+ */
23
+ export type PluginOptions<TEnv extends TemplateEnv = TemplateEnv> = {
24
+ template?: string;
25
+ templateEnvPrefix?: string;
26
+ generateTemplateEnv?: TemplateEnvFn<TEnv>;
27
+ logger?: Pick<Console, 'debug' | 'info' | 'warn' | 'error'>;
28
+ };
29
+
30
+ /**
31
+ * Represents the default environment configuration for the Fusion SPA.
32
+ */
33
+ const defaultEnv: Partial<TemplateEnv> = {
34
+ title: 'Fusion SPA',
35
+ bootstrap: '/@fusion-spa-bootstrap.js',
36
+ };
37
+
38
+ export const plugin = <TEnv extends TemplateEnv = TemplateEnv>(
39
+ options?: PluginOptions<TEnv>,
40
+ ): Plugin => {
41
+ // SPA index template
42
+ const indexTemplate = options?.template ?? defaultTemplate;
43
+ const log = options?.logger;
44
+
45
+ return {
46
+ name: 'fusion-framework-plugin-spa',
47
+ resolveId(id) {
48
+ // resolve resource aliases to the correct path
49
+ switch (id) {
50
+ case '/@fusion-spa-bootstrap.js':
51
+ return new URL('./html/bootstrap.js', import.meta.url).pathname;
52
+ case '/@fusion-spa-sw.js':
53
+ return new URL('./html/sw.js', import.meta.url).pathname;
54
+ }
55
+ },
56
+ config(config, configEnv) {
57
+ const templateEnvPrefix = options?.templateEnvPrefix ?? 'FUSION_SPA_';
58
+ // generate environment variables from plugin options
59
+ const pluginEnvObj = { ...defaultEnv, ...options?.generateTemplateEnv?.(configEnv) };
60
+ const pluginEnv = objectToEnv(pluginEnvObj ?? defaultEnv, templateEnvPrefix);
61
+
62
+ log?.debug('plugin config environment\n', pluginEnv);
63
+
64
+ // load environment variables from files
65
+ const loadedEnv = loadEnvironment(config, configEnv, templateEnvPrefix);
66
+
67
+ log?.debug('plugin loaded environment\n', pluginEnv);
68
+
69
+ const env = mergeWith(pluginEnv, loadedEnv);
70
+
71
+ log?.debug('plugin environment\n', env);
72
+
73
+ // define environment variables
74
+ config.define ??= {};
75
+ for (const [key, value] of Object.entries(env)) {
76
+ config.define[`import.meta.env.${key}`] = value;
77
+ }
78
+ log?.info(`plugin configured for ${env.FUSION_SPA_PORTAL_ID}`);
79
+ },
80
+ configureServer(server) {
81
+ // Apply SPA fallback
82
+ server.middlewares.use(async (req, res, next) => {
83
+ // Skip if this is not a GET request or the request is not for HTML
84
+ if (!req.url || req.method !== 'GET' || !req.headers.accept?.includes('text/html')) {
85
+ return next();
86
+ }
87
+
88
+ const html = await server.transformIndexHtml(req.url, indexTemplate, req.originalUrl);
89
+
90
+ res.writeHead(200, {
91
+ 'content-type': 'text/html',
92
+ 'content-length': Buffer.byteLength(html),
93
+ 'cache-control': 'no-cache',
94
+ ...server.config.server.headers,
95
+ });
96
+
97
+ return res.end(html);
98
+ });
99
+ },
100
+ };
101
+ };
102
+
103
+ export default plugin;
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { ConfigEnv } from 'vite';
2
+
3
+ export type TemplateEnv = Record<string, unknown>;
4
+
5
+ export type ResourceConfiguration = {
6
+ url: string;
7
+ scopes?: string[];
8
+ rewrite?: string;
9
+ };
10
+
11
+ /**
12
+ * Represents the environment configuration for a template.
13
+ */
14
+ export type FusionTemplateEnv = {
15
+ /** Document title */
16
+ title: string;
17
+ /** Template bootstrap file path */
18
+ bootstrap: string;
19
+
20
+ /** Id of the portal to load */
21
+ portal: {
22
+ id: string;
23
+ tag?: string;
24
+ };
25
+
26
+ /** Service discovery configuration */
27
+ serviceDiscovery: {
28
+ url: string;
29
+ scopes: string[];
30
+ };
31
+
32
+ /** MSAL configuration */
33
+ msal: {
34
+ tenantId: string;
35
+ clientId: string;
36
+ redirectUri: string;
37
+ requiresAuth: string;
38
+ };
39
+ /** Service worker configuration */
40
+ serviceWorker: {
41
+ resources: ResourceConfiguration[];
42
+ };
43
+ };
44
+
45
+ /**
46
+ * A function type that generates a partial environment configuration for a template.
47
+ *
48
+ * @template TEnv - The type of the environment configuration. Defaults to `TemplateEnv`.
49
+ * @param configEnv - The configuration environment provided as input.
50
+ * @returns A partial environment configuration of type `TEnv`, or `undefined` if no configuration is provided.
51
+ */
52
+ export type TemplateEnvFn<TEnv extends TemplateEnv> = (
53
+ configEnv: ConfigEnv,
54
+ ) => Partial<TEnv> | undefined;
@@ -0,0 +1,29 @@
1
+ import { type ConfigEnv, loadEnv, type UserConfig } from 'vite';
2
+ import { resolve } from 'node:path';
3
+
4
+ /**
5
+ * Loads environment variables for a Vite project based on the provided configuration and environment.
6
+ *
7
+ * @see {@link http://vite.dev/guide/env-and-mode.html#env-files}
8
+ *
9
+ * @param config - The user configuration object, which includes properties such as `root` and `envDir`.
10
+ * - `root`: The root directory of the project. Defaults to the current working directory if not specified.
11
+ * - `envDir`: The directory containing environment files. If not specified, defaults to the root directory.
12
+ * @param env - The environment configuration object.
13
+ * - `mode`: The mode in which the application is running (e.g., 'development', 'production').
14
+ * @returns A record of environment variables prefixed with `FUSION_SPA_`.
15
+ */
16
+ export function loadEnvironment(
17
+ config: UserConfig,
18
+ env: ConfigEnv,
19
+ namespace = 'FUSION_SPA_',
20
+ ): Record<string, string> {
21
+ // resolve the root directory
22
+ const resolvedRoot = resolve(config.root || process.cwd());
23
+ // resolve the environment directory
24
+ const envDir = config.envDir ? resolve(resolvedRoot, config.envDir) : resolvedRoot;
25
+ // load environment variables from the specified directory
26
+ return loadEnv(env.mode, envDir, namespace);
27
+ }
28
+
29
+ export default loadEnvironment;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Converts a nested object into a flat object with keys in snake_case and uppercase.
3
+ * Nested keys are prefixed with their parent keys, separated by underscores.
4
+ * Non-object values are stringified.
5
+ *
6
+ * @param obj - The input object to be flattened and converted.
7
+ * @param prefix - An optional prefix to prepend to the keys (default is an empty string).
8
+ * @returns A flat object with snake_case, uppercase keys and stringified values.
9
+ *
10
+ * @example
11
+ * ```typescript
12
+ * const input = {
13
+ * someKey: "value",
14
+ * nestedObject: {
15
+ * anotherKey: 42,
16
+ * deepNested: {
17
+ * finalKey: true
18
+ * }
19
+ * }
20
+ * };
21
+ *
22
+ * const result = objectToEnv(input);
23
+ * console.log(result);
24
+ * // Output:
25
+ * // {
26
+ * // SOME_KEY: "\"value\"",
27
+ * // NESTED_OBJECT_ANOTHER_KEY: "42",
28
+ * // NESTED_OBJECT_DEEP_NESTED_FINAL_KEY: "true"
29
+ * // }
30
+ * ```
31
+ */
32
+ export function objectToEnv(obj: object, prefix = 'FUSION_SPA'): Record<string, string> {
33
+ return Object.entries(obj).reduce((result, [key, value]) => {
34
+ // Convert camelCase to snake_case and uppercase
35
+ const snakeKey = key.replace(/([A-Z])/g, '_$1').toUpperCase();
36
+
37
+ const newPrefix = prefix ? `${prefix.replace(/_$/, '')}_${snakeKey}` : snakeKey;
38
+
39
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
40
+ // Recursively flatten nested objects
41
+ return Object.assign(result, objectToEnv(value, newPrefix));
42
+ }
43
+ // Stringify non-object values
44
+ return Object.assign(result, { [newPrefix]: JSON.stringify(value) });
45
+ }, {});
46
+ }
47
+
48
+ export default objectToEnv;
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Generated by genversion.
2
+ export const version = '1.0.0-next.0';
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist/esm",
5
+ "rootDir": "src",
6
+ "declarationDir": "./dist/types",
7
+ "baseUrl": ".",
8
+ "types": ["vite/client"]
9
+ },
10
+ "include": ["src/**/*"],
11
+ "exclude": ["node_modules"]
12
+ }