UNPKG

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