extensionConfig.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import * as path from 'path';
  4. import * as webpack from 'webpack';
  5. import { Build } from './build';
  6. import { WPPlugin } from './webpack-plugins';
  7. import { merge } from 'webpack-merge';
  8. import * as fs from 'fs-extra';
  9. import * as glob from 'glob';
  10. import Ajv from 'ajv';
  11. import { readJSONFile, writeJSONFile } from '@jupyterlab/buildutils';
  12. const baseConfig = require('./webpack.config.base');
  13. const { ModuleFederationPlugin } = webpack.container;
  14. export interface IOptions {
  15. packagePath?: string;
  16. corePath?: string;
  17. staticUrl?: string;
  18. mode?: 'development' | 'production';
  19. devtool?: string;
  20. watchMode?: boolean;
  21. }
  22. function generateConfig({
  23. packagePath = '',
  24. corePath = '',
  25. staticUrl = '',
  26. mode = 'production',
  27. devtool = mode === 'development' ? 'source-map' : undefined,
  28. watchMode = false
  29. }: IOptions = {}): webpack.Configuration[] {
  30. const data = require(path.join(packagePath, 'package.json'));
  31. const ajv = new Ajv({ useDefaults: true });
  32. const validate = ajv.compile(require('../metadata_schema.json'));
  33. let valid = validate(data.jupyterlab ?? {});
  34. if (!valid) {
  35. console.error(validate.errors);
  36. process.exit(1);
  37. }
  38. const outputPath = path.join(packagePath, data.jupyterlab['outputDir']);
  39. const staticPath = path.join(outputPath, 'static');
  40. // Handle the extension entry point and the lib entry point, if different
  41. const index = require.resolve(packagePath);
  42. const exposes: { [id: string]: string } = {
  43. './index': index
  44. };
  45. const extension = data.jupyterlab.extension;
  46. if (extension === true) {
  47. exposes['./extension'] = index;
  48. } else if (typeof extension === 'string') {
  49. exposes['./extension'] = path.join(packagePath, extension);
  50. }
  51. const mimeExtension = data.jupyterlab.mimeExtension;
  52. if (mimeExtension === true) {
  53. exposes['./mimeExtension'] = index;
  54. } else if (typeof mimeExtension === 'string') {
  55. exposes['./mimeExtension'] = path.join(packagePath, mimeExtension);
  56. }
  57. if (typeof data.styleModule === 'string') {
  58. exposes['./style'] = path.join(packagePath, data.styleModule);
  59. } else if (typeof data.style === 'string') {
  60. exposes['./style'] = path.join(packagePath, data.style);
  61. }
  62. const coreData = require(path.join(corePath, 'package.json'));
  63. let shared: any = {};
  64. // Start with core package versions.
  65. const coreDeps: any = {
  66. ...coreData.dependencies,
  67. ...(coreData.resolutions ?? {})
  68. };
  69. // Alow extensions to match a wider range than the core dependency
  70. // To ensure forward compatibility.
  71. Object.keys(coreDeps).forEach(element => {
  72. shared[element] = {
  73. requiredVersion: coreDeps[element].replace('~', '^'),
  74. import: false
  75. };
  76. });
  77. // Add package dependencies.
  78. Object.keys(data.dependencies).forEach(element => {
  79. // TODO: make sure that the core dependency semver range is a subset of our
  80. // data.depencies version range for any packages in the core deps.
  81. if (!shared[element]) {
  82. shared[element] = {};
  83. }
  84. });
  85. // Set core packages as singletons that are not bundled.
  86. coreData.jupyterlab.singletonPackages.forEach((element: string) => {
  87. if (!shared[element]) {
  88. shared[element] = {};
  89. }
  90. shared[element].import = false;
  91. shared[element].singleton = true;
  92. });
  93. // Now we merge in the sharedPackages configuration provided by the extension.
  94. const sharedPackages = data.jupyterlab.sharedPackages ?? {};
  95. // Delete any modules that are explicitly not shared
  96. Object.keys(sharedPackages).forEach(pkg => {
  97. if (sharedPackages[pkg] === false) {
  98. delete shared[pkg];
  99. delete sharedPackages[pkg];
  100. }
  101. });
  102. // Transform the sharedPackages information into valid webpack config
  103. Object.keys(sharedPackages).forEach(pkg => {
  104. // Convert `bundled` to `import`
  105. if (sharedPackages[pkg].bundled === false) {
  106. sharedPackages[pkg].import = false;
  107. } else if (
  108. sharedPackages[pkg].bundled === true &&
  109. shared[pkg]?.import === false
  110. ) {
  111. // We can't delete a key in the merge, so we have to delete it in the source
  112. delete shared[pkg].import;
  113. }
  114. delete sharedPackages[pkg].bundled;
  115. });
  116. shared = merge(shared, sharedPackages);
  117. // add the root module itself to shared
  118. if (shared[data.name]) {
  119. console.error(
  120. `The root package itself '${data.name}' may not specified as a shared dependency.`
  121. );
  122. }
  123. shared[data.name] = {
  124. version: data.version,
  125. singleton: true,
  126. import: index
  127. };
  128. // Ensure a clean output directory - remove files but not the directory
  129. // in case it is a symlink
  130. fs.emptyDirSync(outputPath);
  131. const extras = Build.ensureAssets({
  132. packageNames: [],
  133. packagePaths: [packagePath],
  134. output: staticPath,
  135. schemaOutput: outputPath,
  136. themeOutput: outputPath
  137. });
  138. fs.copyFileSync(
  139. path.join(packagePath, 'package.json'),
  140. path.join(outputPath, 'package.json')
  141. );
  142. class CleanupPlugin {
  143. apply(compiler: any) {
  144. compiler.hooks.done.tap('Cleanup', (stats: any) => {
  145. const newlyCreatedAssets = stats.compilation.assets;
  146. // Clear out any remoteEntry files that are stale
  147. // https://stackoverflow.com/a/40370750
  148. const files = glob.sync(path.join(staticPath, 'remoteEntry.*.js'));
  149. let newEntry = '';
  150. const unlinked: string[] = [];
  151. files.forEach(file => {
  152. const fileName = path.basename(file);
  153. if (!newlyCreatedAssets[fileName]) {
  154. fs.unlinkSync(path.resolve(file));
  155. unlinked.push(fileName);
  156. } else {
  157. newEntry = fileName;
  158. }
  159. });
  160. if (unlinked.length > 0) {
  161. console.log('Removed old assets: ', unlinked);
  162. }
  163. // Find the remoteEntry file and add it to the package.json metadata
  164. const data = readJSONFile(path.join(outputPath, 'package.json'));
  165. const _build: any = {
  166. load: path.join('static', newEntry)
  167. };
  168. if (exposes['./extension'] !== undefined) {
  169. _build.extension = './extension';
  170. }
  171. if (exposes['./mimeExtension'] !== undefined) {
  172. _build.mimeExtension = './mimeExtension';
  173. }
  174. if (exposes['./style'] !== undefined) {
  175. _build.style = './style';
  176. }
  177. data.jupyterlab._build = _build;
  178. writeJSONFile(path.join(outputPath, 'package.json'), data);
  179. });
  180. }
  181. }
  182. // Allow custom webpack config
  183. let webpackConfigPath = data.jupyterlab['webpackConfig'];
  184. let webpackConfig = {};
  185. // Use the custom webpack config only if the path to the config
  186. // is specified in package.json (opt-in)
  187. if (webpackConfigPath) {
  188. webpackConfigPath = path.join(packagePath, webpackConfigPath);
  189. if (fs.existsSync(webpackConfigPath)) {
  190. webpackConfig = require(webpackConfigPath);
  191. }
  192. }
  193. let plugins = [
  194. new ModuleFederationPlugin({
  195. name: data.name,
  196. library: {
  197. type: 'var',
  198. name: ['_JUPYTERLAB', data.name]
  199. },
  200. filename: 'remoteEntry.[contenthash].js',
  201. exposes,
  202. shared
  203. }),
  204. new CleanupPlugin()
  205. ];
  206. if (mode === 'production') {
  207. plugins.push(
  208. new WPPlugin.JSONLicenseWebpackPlugin({
  209. excludedPackageTest: packageName => packageName === data.name
  210. })
  211. );
  212. }
  213. // Add version argument when in production so the Jupyter server
  214. // allows caching of files (i.e., does not set the CacheControl header to no-cache to prevent caching static files)
  215. let filename = '[name].[contenthash].js';
  216. if (mode === 'production') {
  217. filename += '?v=[contenthash]';
  218. }
  219. const config = [
  220. merge(
  221. baseConfig,
  222. {
  223. mode,
  224. devtool,
  225. entry: {},
  226. output: {
  227. filename,
  228. path: staticPath,
  229. publicPath: staticUrl || 'auto'
  230. },
  231. module: {
  232. rules: [{ test: /\.html$/, use: 'file-loader' }]
  233. },
  234. plugins
  235. },
  236. webpackConfig
  237. )
  238. ].concat(extras);
  239. if (mode === 'development') {
  240. const logPath = path.join(outputPath, 'build_log.json');
  241. fs.writeFileSync(logPath, JSON.stringify(config, null, ' '));
  242. }
  243. return config;
  244. }
  245. export default generateConfig;