UNPKG

17.2 kBJavaScriptView Raw
1/* eslint-disable import/max-dependencies */
2import { statSync } from "fs"
3import { resolve } from "url"
4import { fileRead, fileWrite } from "@dmail/helper"
5import { createOperation } from "@dmail/cancellation"
6import {
7 hrefToPathname,
8 hrefToScheme,
9 pathnameToRelativePath,
10 pathnameIsInside,
11 hrefToOrigin,
12} from "@jsenv/href"
13import {
14 pathnameToOperatingSystemPath,
15 operatingSystemPathToPathname,
16 isWindowsPath,
17} from "@jsenv/operating-system-path"
18import { resolveImport, composeTwoImportMaps, normalizeImportMap } from "@jsenv/import-map"
19import { generateImportMapForPackage } from "@jsenv/node-module-import-map"
20import { jsenvBundlingProjectPathname, jsenvBundlingProjectPath } from "./jsenv-bundling-project.js"
21import { generateBabelPluginMapOption } from "./babel-plugin-map/babel-plugin-map.js"
22import { fetchUsingHttp } from "./helpers/fetchUsingHttp.js"
23import { writeSourceMappingURL, parseSourceMappingURL } from "./helpers/source-mapping-url.js"
24
25const {
26 readProjectImportMap,
27 babelHelperMap,
28 generateBabelHelper,
29 cleanCompileCacheFolderIfObsolete,
30 getOrGenerateCompiledFile,
31 transformJs,
32 transformResultToCompilationResult,
33 compilationResultToTransformResult,
34 findAsyncPluginNameInBabelPluginMap,
35} = import.meta.require("@jsenv/core")
36const { minify: minifyCode } = import.meta.require("terser")
37
38export const createJsenvRollupPlugin = async ({
39 cancellationToken,
40 projectPathname,
41 bundleIntoRelativePath,
42 importDefaultExtension,
43 importMapRelativePath,
44 importMapForBundle,
45 importAbstractMap,
46 importFallbackMap,
47 cache,
48 cacheRelativePath,
49 babelPluginMap,
50 convertMap,
51 featureNameArray,
52 minify,
53 format,
54 detectAndTransformIfNeededAsyncInsertedByRollup = format === "global",
55 entryPointMap,
56 logger,
57}) => {
58 const fakeOrigin = "http://example.com"
59
60 const arrayOfHrefToSkipTransform = []
61 const arrayOfAbstractHref = []
62
63 const getArrayOfAbstractHref = () => arrayOfAbstractHref
64
65 const importMapAbstractPath = "/.jsenv/importMap.json"
66 const bundleConstantAbstractPath = "/.jsenv/BUNDLE_CONSTANTS.js"
67
68 babelPluginMap = generateBabelPluginMapOption({
69 projectPathname,
70 format,
71 babelPluginMap,
72 featureNameArray,
73 })
74
75 const bundleConstantsHref = `file://${jsenvBundlingProjectPathname}${bundleConstantAbstractPath}`
76 const importMapHref = `file://${projectPathname}${importMapRelativePath}`
77
78 importFallbackMap = {
79 ...importFallbackMap,
80 // importMap is optionnal
81 [importMapHref]: () => `export default {}`,
82 }
83
84 const chunkId = `${Object.keys(entryPointMap)[0]}.js`
85 importAbstractMap = {
86 ...importAbstractMap,
87 [bundleConstantsHref]: () => `export const chunkId = ${JSON.stringify(chunkId)}`,
88 }
89
90 const importMapForBundling = {
91 imports: {
92 [bundleConstantAbstractPath]: bundleConstantsHref,
93 [importMapAbstractPath]: importMapHref,
94 },
95 }
96
97 Object.keys(babelHelperMap).forEach((babelHelperName) => {
98 const babelHelperPath = babelHelperMap[babelHelperName]
99 if (babelHelperPath.startsWith("@jsenv/core")) {
100 // nothing to do
101 } else {
102 importAbstractMap[babelHelperPath] = () => generateBabelHelper(babelHelperName)
103 }
104 })
105
106 const importMapForProject = await readProjectImportMap({
107 projectPathname,
108 jsenvProjectPathname: jsenvBundlingProjectPathname,
109 importMapRelativePath,
110 logger,
111 })
112 const importMap = normalizeImportMap(
113 [
114 await generateImportMapForPackage({
115 projectPath: jsenvBundlingProjectPath,
116 rootProjectPath: pathnameToOperatingSystemPath(projectPathname),
117 onWarn: ({ message }) => logger.warn(message),
118 }),
119 importMapForBundling,
120 importMapForProject,
121 importMapForBundle,
122 ].reduce((previous, current) => composeTwoImportMaps(previous, current), {}),
123 fakeOrigin,
124 )
125
126 const compileCacheFolderRelativePath = `${bundleIntoRelativePath}${cacheRelativePath}`
127 if (cache) {
128 const cacheMeta = {
129 featureNameArray,
130 convertMap,
131
132 // importMap,
133 // importMap influences how the files are resolved
134 // but not how they are compiled and cached
135
136 // format,
137 // not needed because derived from bundleIntoRelativePath
138
139 // babelPluginMap,
140 // not needed because derived from featureNameArray
141
142 // minify,
143 // not needed, the bundle itself is not cached
144 // and only the bundle is minified
145 }
146 await cleanCompileCacheFolderIfObsolete({
147 projectPathname,
148 compileCacheFolderRelativePath,
149 cacheMeta,
150 cleanCallback: (cacheFolderPath) => {
151 logger.warn(`remove cache folder ${cacheFolderPath}`)
152 },
153 })
154 }
155
156 const jsenvRollupPlugin = {
157 name: "jsenv",
158
159 resolveId: (specifier, importer) => {
160 let importerHref
161 if (importer) {
162 if (isWindowsPath(importer)) {
163 importer = operatingSystemPathToPathname(importer)
164 }
165 // 99% of the time importer is an operating system path
166 // here we ensure / is resolved against project by forcing an url resolution
167 // prefixing with origin
168 if (importer.startsWith(`${projectPathname}/`)) {
169 importerHref = `${fakeOrigin}${pathnameToRelativePath(importer, projectPathname)}`
170 } else if (hrefToScheme(importer)) {
171 if (importer.startsWith(`file://${projectPathname}/`)) {
172 importerHref = `${fakeOrigin}${pathnameToRelativePath(
173 hrefToPathname(importer),
174 projectPathname,
175 )}`
176 } else {
177 // there is already a scheme (http, https, file), keep it
178 // it means there is an absolute import starting with file:// or http:// for instance.
179 importerHref = importer
180 }
181 } else {
182 throw createImporterOutsideProjectError({ importer, projectPathname })
183 }
184 } else {
185 importerHref = fakeOrigin
186 }
187
188 const resolvedHref = resolveImport({
189 specifier,
190 importer: importerHref,
191 importMap,
192 defaultExtension: importDefaultExtension,
193 })
194
195 let href
196 if (resolvedHref.startsWith("file://")) {
197 if (!pathnameIsInside(hrefToPathname(resolvedHref), projectPathname)) {
198 throw new Error(
199 createMustBeInsideProjectMessage({
200 href: resolvedHref,
201 specifier,
202 importer,
203 projectPathname,
204 }),
205 )
206 }
207 href = resolvedHref
208 } else if (hrefToOrigin(resolvedHref) === fakeOrigin) {
209 href = `file://${projectPathname}${hrefToPathname(resolvedHref)}`
210 } else {
211 // allow external import like import "https://cdn.com/jquery.js"
212 href = resolvedHref
213 }
214
215 if (href.startsWith("file://")) {
216 // TODO: open an issue rollup side
217 // to explain the issue here
218 // which is that if id is not an os path
219 // sourcemap will be broken
220 // (likely because they apply node.js path.resolve)
221 // they could either use an other resolution system
222 // or provide the ability to change how it's resolved
223 // so that the sourcemap.sources does not get broken
224
225 // rollup works with operating system path
226 // return os path when possible
227 // to ensure we can predict sourcemap.sources returned by rollup
228 const filePathame = hrefToPathname(href)
229 const filePath = pathnameToOperatingSystemPath(filePathame)
230 if (href in importAbstractMap) {
231 return filePath
232 }
233 // file presence is optionnal
234 if (href in importFallbackMap) {
235 return filePath
236 }
237
238 let stats
239 try {
240 stats = statSync(filePath)
241 } catch (e) {
242 if (e.code === "ENOENT") {
243 throw new Error(
244 createFileNotFoundForImportMessage({
245 path: filePath,
246 specifier,
247 importer,
248 }),
249 )
250 }
251 throw e
252 }
253
254 if (!stats.isFile()) {
255 throw new Error(
256 createUnexpectedStatsForImportMessage({
257 path: filePath,
258 stats,
259 specifier,
260 importer,
261 }),
262 )
263 }
264
265 return filePath
266 }
267
268 return href
269 },
270
271 // https://rollupjs.org/guide/en#resolvedynamicimport
272 // resolveDynamicImport: (specifier, importer) => {
273
274 // },
275
276 load: async (rollupId) => {
277 const href = rollupIdToHref(rollupId)
278
279 if (href in importAbstractMap) {
280 const generateSourceForImport = importAbstractMap[href]
281 if (typeof generateSourceForImport !== "function") {
282 throw new Error(
283 `importAbstractMap values must be functions, found ${generateSourceForImport} for ${href}`,
284 )
285 }
286
287 return generateAbstractSourceForImport(href, generateSourceForImport)
288 }
289
290 const response = await fetchHref(href)
291 const responseStatus = response.status
292
293 if (!responseStatusIsOk(responseStatus)) {
294 if (responseStatus === 404 && href in importFallbackMap) {
295 return generateAbstractSourceForImport(href, importFallbackMap[href])
296 }
297 throw new Error(
298 createUnexpectedResponseStatusMessage({
299 responseStatus,
300 href,
301 }),
302 )
303 }
304
305 let source = response.body
306
307 if (href.endsWith(".json")) {
308 source = `export default ${source}`
309 }
310
311 const sourcemapParsingResult = parseSourceMappingURL(source)
312
313 if (!sourcemapParsingResult) {
314 return { code: source }
315 }
316
317 if (sourcemapParsingResult.sourcemapString) {
318 return { code: source, map: JSON.parse(sourcemapParsingResult.sourcemapString) }
319 }
320
321 const resolvedSourceMappingURL = resolve(href, sourcemapParsingResult.sourcemapURL)
322 const sourcemapResponse = await fetchHref(resolvedSourceMappingURL)
323 const sourcemapResponseStatus = sourcemapResponse.status
324 if (!responseStatusIsOk(sourcemapResponse)) {
325 logger.warn(
326 createUnexpectedResponseStatusMessage({
327 responseStatus: sourcemapResponseStatus,
328 href: resolvedSourceMappingURL,
329 }),
330 )
331 return { code: source }
332 }
333
334 return { code: source, map: JSON.parse(sourcemapResponse.body) }
335 },
336
337 // resolveImportMeta: () => {}
338
339 transform: async (source, rollupId) => {
340 const sourceHref = rollupIdToHref(rollupId)
341
342 if (arrayOfHrefToSkipTransform.includes(sourceHref)) {
343 return null
344 }
345
346 const transform = async () => {
347 const { code, map } = await transformJs({
348 source,
349 sourceHref,
350 projectPathname,
351 babelPluginMap,
352 convertMap,
353 // false, rollup will take care to transform module into whatever format
354 transformModuleIntoSystemFormat: false,
355 })
356 return { code, map }
357 }
358
359 if (!cache || !sourceHref.startsWith(`file://${projectPathname}/`)) {
360 return transform()
361 }
362
363 const sourcePathname = hrefToPathname(sourceHref)
364 const sourceRelativePath = pathnameToRelativePath(sourcePathname, projectPathname)
365
366 const { compileResult } = await getOrGenerateCompiledFile({
367 projectPathname,
368 compileCacheFolderRelativePath,
369 sourceRelativePath,
370 compile: async () => {
371 const transformResult = await transform()
372 return transformResultToCompilationResult(transformResult, {
373 source,
374 sourceHref,
375 projectPathname,
376 })
377 },
378 })
379
380 return compilationResultToTransformResult(compileResult)
381 },
382
383 renderChunk: (source) => {
384 if (!minify) return null
385
386 // https://github.com/terser-js/terser#minify-options
387 const minifyOptions = format === "global" ? { toplevel: false } : { toplevel: true }
388 const result = minifyCode(source, {
389 sourceMap: true,
390 ...minifyOptions,
391 })
392 if (result.error) {
393 throw result.error
394 } else {
395 return result
396 }
397 },
398
399 writeBundle: async (bundle) => {
400 if (detectAndTransformIfNeededAsyncInsertedByRollup) {
401 await transformAsyncInsertedByRollup({
402 projectPathname,
403 bundleIntoRelativePath,
404 babelPluginMap,
405 bundle,
406 })
407 }
408
409 Object.keys(bundle).forEach((bundleFilename) => {
410 logger.info(`-> ${projectPathname}${bundleIntoRelativePath}/${bundleFilename}`)
411 })
412 },
413 }
414
415 const markHrefAsAbstract = (href) => {
416 if (!arrayOfAbstractHref.includes(href)) {
417 arrayOfAbstractHref.push(href)
418 }
419 }
420
421 const generateAbstractSourceForImport = async (href, generateSource) => {
422 markHrefAsAbstract(href)
423
424 const returnValue = await generateSource()
425
426 if (typeof returnValue === "string") {
427 return { code: returnValue }
428 }
429
430 const { skipTransform, code, map } = returnValue
431
432 if (skipTransform) {
433 arrayOfHrefToSkipTransform.push(href)
434 }
435 return { code, map }
436 }
437
438 const rollupIdToHref = (rollupId) => {
439 if (isWindowsPath(rollupId)) {
440 return `file://${operatingSystemPathToPathname(rollupId)}`
441 }
442
443 if (hrefToScheme(rollupId)) {
444 return rollupId
445 }
446
447 return `file://${operatingSystemPathToPathname(rollupId)}`
448 }
449
450 const fetchHref = async (href) => {
451 // this code allow you to have http/https dependency for convenience
452 // but maybe we should warn about this.
453 // it could also be vastly improved using a basic in memory cache
454 if (href.startsWith("http://")) {
455 const response = await fetchUsingHttp(href, { cancellationToken })
456 return response
457 }
458
459 if (href.startsWith("https://")) {
460 const response = await fetchUsingHttp(href, { cancellationToken })
461 return response
462 }
463
464 if (href.startsWith("file:///")) {
465 try {
466 const code = await createOperation({
467 cancellationToken,
468 start: () => fileRead(pathnameToOperatingSystemPath(hrefToPathname(href))),
469 })
470 return {
471 status: 200,
472 body: code,
473 }
474 } catch (e) {
475 if (e.code === "ENOENT") {
476 return {
477 status: 404,
478 }
479 }
480 return {
481 status: 500,
482 }
483 }
484 }
485
486 throw new Error(`unsupported href: ${href}`)
487 }
488
489 return {
490 jsenvRollupPlugin,
491 getArrayOfAbstractHref,
492 }
493}
494
495const responseStatusIsOk = (responseStatus) => responseStatus >= 200 && responseStatus < 300
496
497const createFileNotFoundForImportMessage = ({
498 path,
499 specifier,
500 importer,
501}) => `import file not found.
502--- path ---
503${path}
504--- specifier ---
505${specifier}
506--- importer ---
507${importer}`
508
509const createUnexpectedStatsForImportMessage = ({
510 path,
511 stats,
512 specifier,
513 importer,
514}) => `unexpected import file stats.
515--- path ---
516${path}
517--- found ---
518${stats.isDirectory()} ? 'directory' : 'not-a-file'
519--- specifier ---
520${specifier}
521--- importer ---
522${importer}`
523
524const createUnexpectedResponseStatusMessage = ({
525 responseStatus,
526 href,
527}) => `unexpected response status.
528--- response status ---
529${responseStatus}
530--- href ---
531${href}`
532
533const createMustBeInsideProjectMessage = ({ href, specifier, importer, projectPathname }) => `
534source must be inside project.
535--- href ---
536${href}
537--- specifier ---
538${specifier}
539--- importer ---
540${importer}
541--- project path ---
542${pathnameToOperatingSystemPath(projectPathname)}`
543
544const createImporterOutsideProjectError = ({ importer, projectPathname }) =>
545 new Error(`importer must be inside project
546 importer: ${importer}
547 project: ${pathnameToOperatingSystemPath(projectPathname)}`)
548
549const transformAsyncInsertedByRollup = async ({
550 projectPathname,
551 bundleIntoRelativePath,
552 babelPluginMap,
553 bundle,
554}) => {
555 const bundleFolderPathname = `${projectPathname}${bundleIntoRelativePath}`
556 const asyncPluginName = findAsyncPluginNameInBabelPluginMap(babelPluginMap)
557
558 if (!asyncPluginName) return
559
560 // we have to do this because rollup ads
561 // an async wrapper function without transpiling it
562 // if your bundle contains a dynamic import
563 await Promise.all(
564 Object.keys(bundle).map(async (bundleFilename) => {
565 const bundleInfo = bundle[bundleFilename]
566
567 const { code, map } = await transformJs({
568 projectPathname,
569 source: bundleInfo.code,
570 sourceHref: `file://${operatingSystemPathToPathname(bundleFilename)}`,
571 sourceMap: bundleInfo.map,
572 babelPluginMap: { [asyncPluginName]: babelPluginMap[asyncPluginName] },
573 transformModuleIntoSystemFormat: false, // already done by rollup
574 transformGenerator: false, // already done
575 })
576
577 await Promise.all([
578 fileWrite(
579 pathnameToOperatingSystemPath(`${bundleFolderPathname}/${bundleFilename}`),
580 writeSourceMappingURL(code, `./${bundleFilename}.map`),
581 ),
582 fileWrite(
583 pathnameToOperatingSystemPath(`${bundleFolderPathname}/${bundleFilename}.map`),
584 JSON.stringify(map),
585 ),
586 ])
587 }),
588 )
589}