@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 +82 -0
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/package.json +52 -0
- package/src/html/bootstrap.ts +90 -0
- package/src/html/index.html.ts +52 -0
- package/src/html/index.ts +1 -0
- package/src/html/register-service-worker.ts +67 -0
- package/src/html/sw.ts +211 -0
- package/src/index.ts +3 -0
- package/src/plugin.ts +103 -0
- package/src/types.ts +54 -0
- package/src/util/load-env.ts +29 -0
- package/src/util/object-to-env.ts +48 -0
- package/src/version.ts +2 -0
- package/tsconfig.json +12 -0
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
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
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
|
+
}
|