/
ConsoleRemotePlugin.ts
442 lines (384 loc) · 15.3 KB
/
ConsoleRemotePlugin.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
import * as path from 'path';
import {
DynamicRemotePlugin,
EncodedExtension,
WebpackSharedConfig,
WebpackSharedObject,
} from '@openshift/dynamic-plugin-sdk-webpack';
import * as glob from 'glob';
import * as _ from 'lodash';
import * as readPkg from 'read-pkg';
import * as semver from 'semver';
import * as webpack from 'webpack';
import { ConsolePluginBuildMetadata } from '../build-types';
import { extensionsFile } from '../constants';
import { sharedPluginModules, getSharedModuleMetadata } from '../shared-modules';
import { DynamicModuleMap, getDynamicModuleMap } from '../utils/dynamic-module-parser';
import { parseJSONC } from '../utils/jsonc';
import { loadSchema } from '../utils/schema';
import { ExtensionValidator } from '../validation/ExtensionValidator';
import { SchemaValidator } from '../validation/SchemaValidator';
import { ValidationResult } from '../validation/ValidationResult';
import { DynamicModuleImportLoaderOptions } from './loaders/dynamic-module-import-loader';
type ConsolePluginPackageJSON = readPkg.PackageJson & {
consolePlugin?: ConsolePluginBuildMetadata;
};
const dynamicModuleImportLoader =
'@openshift-console/dynamic-plugin-sdk-webpack/lib/webpack/loaders/dynamic-module-import-loader';
const loadPluginPackageJSON = () => readPkg.sync({ normalize: false }) as ConsolePluginPackageJSON;
const loadVendorPackageJSON = (moduleName: string) =>
// eslint-disable-next-line @typescript-eslint/no-var-requires
require(`${moduleName}/package.json`) as readPkg.PackageJson;
const getVendorPackageVersion = (moduleName: string) => {
try {
return loadVendorPackageJSON(moduleName).version;
} catch (e) {
return undefined;
}
};
const getPackageDependencies = (pkg: readPkg.PackageJson) => ({
...pkg.devDependencies,
...pkg.dependencies,
});
const getPatternFlyStyles = (baseDir: string) =>
glob.sync(`${baseDir}/node_modules/@patternfly/react-styles/**/*.css`);
// https://webpack.js.org/plugins/module-federation-plugin/#sharing-hints
const getWebpackSharedModules = () =>
sharedPluginModules.reduce<WebpackSharedObject>((acc, moduleName) => {
const { singleton, allowFallback } = getSharedModuleMetadata(moduleName);
const moduleConfig: WebpackSharedConfig = { singleton };
if (!allowFallback) {
moduleConfig.import = false;
}
acc[moduleName] = moduleConfig;
return acc;
}, {});
const getWebpackSharedDynamicModules = (
pkg: ConsolePluginPackageJSON,
moduleName: string,
moduleRequests: string[],
) => {
const pluginDeps = getPackageDependencies(pkg);
const moduleVersion = getVendorPackageVersion(moduleName);
const moduleVersionRange = pluginDeps[moduleName];
const moduleConfig: WebpackSharedConfig = {};
if (semver.valid(moduleVersion)) {
moduleConfig.version = moduleVersion;
}
if (semver.validRange(moduleVersionRange)) {
moduleConfig.requiredVersion = moduleVersionRange;
}
return moduleRequests.reduce<WebpackSharedObject>((acc, request) => {
acc[`${moduleName}/${request}`] = moduleConfig;
return acc;
}, {});
};
/**
* Perform (additional) build-time validation of Console plugin metadata.
*
* Note that `DynamicRemotePlugin` takes care of basic build metadata validation.
* Therefore, this function only performs additional Console specific validations.
*/
const validateConsoleBuildMetadata = (metadata: ConsolePluginBuildMetadata) => {
const result = new ValidationResult('Console plugin metadata');
result.assertions.validDNSSubdomainName(metadata.name, 'metadata.name');
return result;
};
export const validateConsoleExtensionsFileSchema = (
extensions: EncodedExtension[],
description = 'console-extensions.json',
) => {
const schema = loadSchema('console-extensions.json');
return new SchemaValidator(description).validate(schema, extensions);
};
const validateConsoleProvidedSharedModules = (pkg: ConsolePluginPackageJSON) => {
const sdkPkg = loadVendorPackageJSON('@openshift-console/dynamic-plugin-sdk');
const pluginDeps = getPackageDependencies(pkg);
const result = new ValidationResult('package.json');
sharedPluginModules.forEach((moduleName) => {
const { allowFallback } = getSharedModuleMetadata(moduleName);
// Skip modules that allow a fallback version to be provided by the plugin.
// Also skip modules which are not explicitly listed in the plugin's dependencies.
if (allowFallback || !pluginDeps[moduleName]) {
return;
}
const providedVersionRange = sdkPkg.dependencies[moduleName];
const consumedVersion = getVendorPackageVersion(moduleName);
if (semver.validRange(providedVersionRange) && semver.valid(consumedVersion)) {
result.assertThat(
semver.satisfies(consumedVersion, providedVersionRange),
`Console provides shared module ${moduleName} ${providedVersionRange} but plugin uses version ${consumedVersion}`,
);
}
});
return result;
};
export type ConsoleRemotePluginOptions = Partial<{
/**
* Console dynamic plugin metadata.
*
* If not specified, plugin metadata will be parsed from `consolePlugin` object within
* the `package.json` file.
*
* Plugin metadata should meet the following requirements:
*
* - `name` should be the same as `metadata.name` of the corresponding `ConsolePlugin`
* resource on the cluster.
* - `version` must be semver compliant.
* - `dependencies` values must be valid semver ranges or `*` representing any version.
*
* Additional runtime environment specific dependencies available to Console plugins:
*
* - `@console/pluginAPI` - Console web application. This dependency is matched against
* the Console release version, as provided by the Console operator.
*/
pluginMetadata: ConsolePluginBuildMetadata;
/**
* List of extensions contributed by the plugin.
*
* If not specified, extensions will be parsed from `console-extensions.json` file.
*/
extensions: EncodedExtension[];
/**
* Validate extension objects using the `console-extensions.json` schema?
*
* @default true
*/
validateExtensionSchema: boolean;
/**
* Validate integrity of extensions contributed by the plugin?
*
* This option controls whether to use `ExtensionValidator` to check the following criteria:
* - each exposed module must have at least one code reference
* - each code reference must point to a valid webpack module export
*
* @default true
*/
validateExtensionIntegrity: boolean;
/**
* Validate Console provided shared module dependencies?
*
* Console provided shared modules can be reflected as `dependencies` within the manifest of
* the `@openshift-console/dynamic-plugin-sdk` package. For each shared module where a fallback
* version is not allowed, check that the version consumed by the plugin satisfies the expected
* semver range as declared in the Console core SDK package manifest.
*
* @default true
*/
validateSharedModules: boolean;
/**
* Some vendor packages may support dynamic modules to be used with webpack module federation.
*
* If a module request matches the `transformImports` filter, that module will have its imports
* transformed so that any _index_ imports for given vendor packages become imports for specific
* dynamic modules of these vendor packages.
*
* For example, the following import:
* ```ts
* import { Alert, AlertProps, Wizard } from '@patternfly/react-core';
* ```
* will be transformed into:
* ```ts
* import { Alert } from '@patternfly/react-core/dist/dynamic/components/Alert';
* import { AlertProps } from '@patternfly/react-core/dist/dynamic/components/Alert';
* import { Wizard } from '@patternfly/react-core/dist/dynamic/components/Wizard';
* ```
*
* Each dynamic module (such as `@patternfly/react-core/dist/dynamic/components/Alert`) will
* be treated as a separate shared module at runtime. This approach allows for more efficient
* federation of vendor package code, as opposed to sharing the whole vendor package index
* (such as `@patternfly/react-core`) that pulls in all of its code.
*/
sharedDynamicModuleSettings: Partial<{
/**
* Attempt to parse dynamic modules for these packages.
*
* Each package listed here should include a `dist/dynamic` directory containing `package.json`
* files that refer to specific modules of that package.
*
* If not specified, the following packages will be included:
* - `@patternfly/react-core`
* - `@patternfly/react-icons`
* - `@patternfly/react-table`
*/
packageSpecs: Record<
string,
Partial<{
/** @default 'dist/esm/index.js' */
indexModule: string;
/** @default 'module' */
resolutionField: string;
}>
>;
/**
* Import transformations will be applied to modules that match this filter.
*
* If not specified, the following conditions must be all true for a module to be matched:
* - request ends with one of `.js`, `.jsx`, `.ts`, `.tsx`
* - request does not contain `node_modules` path elements (i.e. not a vendor module request),
* _except_ for `@openshift-console/*` packages
*/
transformImports: (moduleRequest: string) => boolean;
}>;
}>;
/**
* Generates Console dynamic plugin remote container and related assets.
*
* Refer to `frontend/packages/console-dynamic-plugin-sdk/src/shared-modules.ts` for details on
* Console application vs. dynamic plugins shared module configuration.
*
* @see {@link sharedPluginModules}
* @see {@link getSharedModuleMetadata}
*/
export class ConsoleRemotePlugin implements webpack.WebpackPluginInstance {
private readonly adaptedOptions: Required<ConsoleRemotePluginOptions>;
private readonly baseDir = process.cwd();
private readonly pkg = loadPluginPackageJSON();
private readonly sharedDynamicModuleMaps: Record<string, DynamicModuleMap>;
constructor(options: ConsoleRemotePluginOptions = {}) {
this.adaptedOptions = {
pluginMetadata: options.pluginMetadata ?? this.pkg.consolePlugin,
extensions: options.extensions ?? parseJSONC(path.resolve(this.baseDir, extensionsFile)),
validateExtensionSchema: options.validateExtensionSchema ?? true,
validateExtensionIntegrity: options.validateExtensionIntegrity ?? true,
validateSharedModules: options.validateSharedModules ?? true,
sharedDynamicModuleSettings: options.sharedDynamicModuleSettings ?? {},
};
if (this.adaptedOptions.validateExtensionSchema) {
validateConsoleExtensionsFileSchema(this.adaptedOptions.extensions).report();
}
if (this.adaptedOptions.validateSharedModules) {
validateConsoleProvidedSharedModules(this.pkg).report();
}
this.sharedDynamicModuleMaps = Object.entries(
this.adaptedOptions.sharedDynamicModuleSettings.packageSpecs ?? {
'@patternfly/react-core': {},
'@patternfly/react-icons': {},
'@patternfly/react-table': {},
},
).reduce<Record<string, DynamicModuleMap>>(
(acc, [pkgName, { indexModule = 'dist/esm/index.js', resolutionField = 'module' }]) => ({
...acc,
[pkgName]: getDynamicModuleMap(
path.resolve(this.baseDir, 'node_modules', pkgName),
indexModule,
resolutionField,
),
}),
{},
);
}
apply(compiler: webpack.Compiler) {
const {
pluginMetadata,
extensions,
validateExtensionIntegrity,
sharedDynamicModuleSettings,
} = this.adaptedOptions;
const {
name,
version,
dependencies,
customProperties,
exposedModules,
displayName,
description,
disableStaticPlugins,
} = pluginMetadata;
const logger = compiler.getInfrastructureLogger(ConsoleRemotePlugin.name);
const publicPath = `/api/plugins/${name}/`;
if (compiler.options.output.publicPath !== undefined) {
logger.warn(`output.publicPath is defined, but will be overridden to ${publicPath}`);
}
compiler.options.output.publicPath = publicPath;
compiler.options.resolve = compiler.options.resolve ?? {};
compiler.options.resolve.alias = compiler.options.resolve.alias ?? {};
// Prevent PatternFly styles from being included in the compilation
getPatternFlyStyles(this.baseDir).forEach((cssFile) => {
if (Array.isArray(compiler.options.resolve.alias)) {
compiler.options.resolve.alias.push({ name: cssFile, alias: false });
} else {
compiler.options.resolve.alias[cssFile] = false;
}
});
const allSharedDynamicModules = Object.entries(this.sharedDynamicModuleMaps).reduce<
WebpackSharedObject
>(
(acc, [moduleName, dynamicModuleMap]) => ({
...acc,
...getWebpackSharedDynamicModules(this.pkg, moduleName, Object.values(dynamicModuleMap)),
}),
{},
);
new DynamicRemotePlugin({
pluginMetadata: {
name,
version,
dependencies,
customProperties: _.merge({}, customProperties, {
console: { displayName, description, disableStaticPlugins },
}),
exposedModules,
},
extensions,
sharedModules: {
...getWebpackSharedModules(),
...allSharedDynamicModules,
},
entryCallbackSettings: {
name: 'loadPluginEntry',
pluginID: `${name}@${version}`,
},
entryScriptFilename:
process.env.NODE_ENV === 'production'
? 'plugin-entry.[fullhash].min.js'
: 'plugin-entry.js',
}).apply(compiler);
validateConsoleBuildMetadata(pluginMetadata).report();
if (validateExtensionIntegrity) {
compiler.hooks.emit.tap(ConsoleRemotePlugin.name, (compilation) => {
const result = new ExtensionValidator('Console plugin extensions').validate(
compilation,
extensions,
exposedModules ?? {},
);
if (result.hasErrors()) {
const error = new webpack.WebpackError('ExtensionValidator has reported errors');
error.details = result.formatErrors();
error.file = extensionsFile;
compilation.errors.push(error);
}
});
}
const transformImports =
sharedDynamicModuleSettings.transformImports ??
((moduleRequest) => {
const isCode = /\.(jsx?|tsx?)$/.test(moduleRequest);
const isVendor = moduleRequest.includes('/node_modules/');
return isCode && (!isVendor || moduleRequest.includes('/node_modules/@openshift-console/'));
});
compiler.hooks.thisCompilation.tap(ConsoleRemotePlugin.name, (compilation) => {
const modifiedModules: string[] = [];
webpack.NormalModule.getCompilationHooks(compilation).beforeLoaders.tap(
ConsoleRemotePlugin.name,
(loaders, normalModule) => {
const { userRequest } = normalModule;
const moduleRequest = userRequest.substring(
userRequest.lastIndexOf('!') === -1 ? 0 : userRequest.lastIndexOf('!') + 1,
);
if (!modifiedModules.includes(moduleRequest) && transformImports(moduleRequest)) {
const loaderOptions: DynamicModuleImportLoaderOptions = {
dynamicModuleMaps: this.sharedDynamicModuleMaps,
resourceMetadata: { jsx: /\.(jsx|tsx)$/.test(moduleRequest) },
};
normalModule.loaders.push({
loader: dynamicModuleImportLoader,
options: loaderOptions,
} as any);
modifiedModules.push(moduleRequest);
}
},
);
});
}
}