UNPKG

11.3 kBJavaScriptView Raw
1/* eslint-disable import/max-dependencies */
2import { resolve } from "url"
3import { fileRead, fileWrite } from "@dmail/helper"
4import { createOperation } from "@dmail/cancellation"
5import { resolvePath, hrefToPathname, hrefToScheme } from "@jsenv/module-resolution"
6import {
7 pathnameToOperatingSystemPath,
8 operatingSystemPathToPathname,
9 pathnameToRelativePathname,
10 isWindowsPath,
11} from "@jsenv/operating-system-path"
12import { generateImportMapForNodeModules } from "@jsenv/node-module-import-map"
13import { transformSource, jsenvTransform, findAsyncPluginNameInBabelPluginMap } from "@jsenv/core"
14import { generateSpecifierMapOption } from "./specifier-map/specifier-map.js"
15import { generateSpecifierDynamicMapOption } from "./specifier-dynamic-map/specifier-dynamic-map.js"
16import { generateBabelPluginMapOption } from "./babel-plugin-map/babel-plugin-map.js"
17import { fetchUsingHttp } from "./helpers/fetchUsingHttp.js"
18import { writeSourceMappingURL, parseSourceMappingURL } from "./helpers/source-mapping-url.js"
19import { readProjectImportMap } from "./read-project-import-map.js"
20import { mergeTwoImportMap } from "./mergeTwoImportMap.js"
21import { jsenvBundlingProjectPath } from "./jsenv-bundling-project.js"
22
23const { minify: minifyCode } = import.meta.require("terser")
24
25export const createJsenvRollupPlugin = async ({
26 cancellationToken,
27 projectPathname,
28 importDefaultExtension,
29 importMapRelativePath,
30 importMapForBundle = {},
31 specifierMap,
32 specifierDynamicMap,
33 origin = "http://example.com",
34 babelPluginMap,
35 convertMap,
36 featureNameArray,
37 minify,
38 format,
39 detectAndTransformIfNeededAsyncInsertedByRollup = format === "global",
40 dir,
41 entryPointMap,
42 logger,
43}) => {
44 const bundlingImportMap = await generateImportMapForNodeModules({
45 projectPath: jsenvBundlingProjectPath,
46 rootProjectPath: pathnameToOperatingSystemPath(projectPathname),
47 })
48 const projectImportMap = await readProjectImportMap({ projectPathname, importMapRelativePath })
49 const importMap = [importMapForBundle, bundlingImportMap, projectImportMap].reduce(
50 (previous, current) => mergeTwoImportMap(previous, current),
51 {},
52 )
53
54 specifierMap = {
55 ...specifierMap,
56 ...generateSpecifierMapOption(),
57 ["/.jsenv/importMap.json"]: `file://${projectPathname}${importMapRelativePath}`,
58 }
59 const chunkId = `${Object.keys(entryPointMap)[0]}.js`
60 specifierDynamicMap = {
61 ...specifierDynamicMap,
62 ...generateSpecifierDynamicMapOption(),
63 ["/BUNDLE_CONSTANTS.js"]: () => `export const chunkId = ${JSON.stringify(chunkId)}`,
64 }
65 babelPluginMap = generateBabelPluginMapOption({
66 projectPathname,
67 format,
68 babelPluginMap,
69 featureNameArray,
70 })
71 // https://github.com/babel/babel/blob/master/packages/babel-core/src/tools/build-external-helpers.js#L1
72
73 const idSkipTransformArray = []
74 const idLoadMap = {}
75
76 const getRollupGenerateOptions = async () => {
77 return {}
78 }
79
80 const jsenvRollupPlugin = {
81 name: "jsenv",
82
83 resolveId: (specifier, importer) => {
84 if (specifier in specifierDynamicMap) {
85 const specifierDynamicMapping = specifierDynamicMap[specifier]
86 if (typeof specifierDynamicMapping !== "function") {
87 throw new Error(
88 `specifier inside specifierDynamicMap must be functions, found ${specifierDynamicMapping} for ${specifier}`,
89 )
90 }
91
92 const osPath = pathnameToOperatingSystemPath(`${projectPathname}${specifier}`)
93 idLoadMap[osPath] = specifierDynamicMapping
94 return osPath
95 }
96
97 if (specifier in specifierMap) {
98 const specifierMapping = specifierMap[specifier]
99 if (typeof specifierMapping !== "string") {
100 throw new Error(
101 `specifier inside specifierMap must be strings, found ${specifierMapping} for ${specifier}`,
102 )
103 }
104 specifier = specifierMapping
105
106 // disable remapping when specifier starts with file://
107 // this is the only way for now to explicitely disable remapping
108 // to target a specific file on filesystem
109 // also remove file:// to keep only the os path
110 // otherwise windows and rollup will not be happy when
111 // searching the files
112 if (specifier.startsWith("file:///")) {
113 specifier = specifier.slice("file://".length)
114 return pathnameToOperatingSystemPath(specifier)
115 }
116 }
117
118 if (!importer) {
119 if (specifier[0] === "/") specifier = specifier.slice(1)
120 return pathnameToOperatingSystemPath(`${projectPathname}/${specifier}`)
121 }
122
123 let importerHref
124 if (isWindowsPath(importer)) {
125 importer = operatingSystemPathToPathname(importer)
126 }
127 // 99% of the time importer is an operating system path
128 // here we ensure / is resolved against project by forcing an url resolution
129 // prefixing with origin
130 if (importer.startsWith(`${projectPathname}/`)) {
131 importerHref = `${origin}${pathnameToRelativePathname(importer, projectPathname)}`
132 } else if (hrefToScheme(importer)) {
133 if (importer.startsWith(`file://${projectPathname}/`)) {
134 importerHref = `${origin}${pathnameToRelativePathname(
135 hrefToPathname(importer),
136 projectPathname,
137 )}`
138 } else {
139 // there is already a scheme (http, https, file), keep it
140 // it means there is an absolute import starting with file:// or http:// for instance.
141 importerHref = importer
142 }
143 } else {
144 throw createImporterOutsideProjectError({ importer, projectPathname })
145 }
146
147 const id = resolvePath({
148 specifier,
149 importer: importerHref,
150 importMap,
151 defaultExtension: importDefaultExtension,
152 })
153
154 // rollup works with operating system path
155 // return os path when possible
156 // to ensure we can predict sourcemap.sources returned by rollup
157 const resolvedIdIsInsideProject = id.startsWith(`${origin}/`)
158 if (resolvedIdIsInsideProject) {
159 const idPathname = hrefToPathname(id)
160 return pathnameToOperatingSystemPath(`${projectPathname}${idPathname}`)
161 }
162
163 return id
164 },
165
166 // https://rollupjs.org/guide/en#resolvedynamicimport
167 // resolveDynamicImport: (specifier, importer) => {
168
169 // },
170
171 load: async (id) => {
172 if (id in idLoadMap) {
173 const returnValue = await idLoadMap[id]()
174 if (typeof returnValue === "string") return returnValue
175 if (returnValue.skipTransform) {
176 idSkipTransformArray.push(id)
177 }
178 return returnValue.code
179 }
180
181 const hasSheme = isWindowsPath(id) ? false : Boolean(hrefToScheme(id))
182 const href = hasSheme ? id : `file://${operatingSystemPathToPathname(id)}`
183 let source = await fetchHref(href)
184
185 if (id.endsWith(".json")) {
186 source = `export default ${source}`
187 }
188
189 const sourcemapParsingResult = parseSourceMappingURL(source)
190
191 if (!sourcemapParsingResult) return { code: source }
192
193 if (sourcemapParsingResult.sourcemapString)
194 return { code: source, map: JSON.parse(sourcemapParsingResult.sourcemapString) }
195
196 const resolvedSourceMappingURL = resolve(href, sourcemapParsingResult.sourcemapURL)
197 const sourcemapString = await fetchHref(resolvedSourceMappingURL)
198 return { code: source, map: JSON.parse(sourcemapString) }
199 },
200
201 // resolveImportMeta: () => {}
202
203 transform: async (source, id) => {
204 if (idSkipTransformArray.includes(id)) {
205 return null
206 }
207
208 let sourceHref
209 if (isWindowsPath(id)) {
210 sourceHref = `file://${operatingSystemPathToPathname(id)}`
211 } else if (hrefToScheme(id)) {
212 sourceHref = id
213 } else {
214 sourceHref = `file://${operatingSystemPathToPathname(id)}`
215 }
216
217 const { code, map } = await transformSource({
218 projectPathname,
219 source,
220 sourceHref,
221 babelPluginMap,
222 convertMap,
223 // false, rollup will take care to transform module into whatever format
224 transformModuleIntoSystemFormat: false,
225 })
226 return { code, map }
227 },
228
229 renderChunk: (source) => {
230 if (!minify) return null
231
232 // https://github.com/terser-js/terser#minify-options
233 const minifyOptions = format === "global" ? { toplevel: false } : { toplevel: true }
234 const result = minifyCode(source, {
235 sourceMap: true,
236 ...minifyOptions,
237 })
238 if (result.error) {
239 throw result.error
240 } else {
241 return result
242 }
243 },
244
245 writeBundle: async (bundle) => {
246 if (detectAndTransformIfNeededAsyncInsertedByRollup) {
247 await transformAsyncInsertedByRollup({ dir, babelPluginMap, bundle })
248 }
249
250 Object.keys(bundle).forEach((bundleFilename) => {
251 logger.log(`-> ${dir}/${bundleFilename}`)
252 })
253 },
254 }
255
256 const fetchHref = async (href) => {
257 // this code allow you to have http/https dependency for convenience
258 // but maybe we should warn about this.
259 // it could also be vastly improved using a basic in memory cache
260 if (href.startsWith("http://")) {
261 const response = await fetchUsingHttp(href, { cancellationToken })
262 ensureResponseSuccess(response)
263 return response.body
264 }
265
266 if (href.startsWith("https://")) {
267 const response = await fetchUsingHttp(href, { cancellationToken })
268 ensureResponseSuccess(response)
269 return response.body
270 }
271
272 if (href.startsWith("file:///")) {
273 const code = await createOperation({
274 cancellationToken,
275 start: () => fileRead(pathnameToOperatingSystemPath(hrefToPathname(href))),
276 })
277 return code
278 }
279
280 return ""
281 }
282
283 return {
284 jsenvRollupPlugin,
285 relativePathAbstractArray: Object.keys(specifierDynamicMap),
286 getRollupGenerateOptions,
287 }
288}
289
290const ensureResponseSuccess = ({ url, status }) => {
291 if (status < 200 || status > 299) {
292 throw new Error(`unexpected response status for ${url}, got ${status}`)
293 }
294}
295
296const createImporterOutsideProjectError = ({ importer, projectPathname }) =>
297 new Error(`importer must be inside project
298 importer: ${importer}
299 project: ${pathnameToOperatingSystemPath(projectPathname)}`)
300
301const transformAsyncInsertedByRollup = async ({ dir, babelPluginMap, bundle }) => {
302 const asyncPluginName = findAsyncPluginNameInBabelPluginMap(babelPluginMap)
303
304 if (!asyncPluginName) return
305
306 // we have to do this because rollup ads
307 // an async wrapper function without transpiling it
308 // if your bundle contains a dynamic import
309 await Promise.all(
310 Object.keys(bundle).map(async (bundleFilename) => {
311 const bundleInfo = bundle[bundleFilename]
312
313 const { code, map } = await jsenvTransform({
314 inputCode: bundleInfo.code,
315 inputMap: bundleInfo.map,
316 inputPath: bundleFilename,
317 babelPluginMap: { [asyncPluginName]: babelPluginMap[asyncPluginName] },
318 transformModuleIntoSystemFormat: false, // already done by rollup
319 })
320
321 await Promise.all([
322 fileWrite(
323 `${dir}/${bundleFilename}`,
324 writeSourceMappingURL(code, `./${bundleFilename}.map`),
325 ),
326 fileWrite(`${dir}/${bundleFilename}.map`, JSON.stringify(map)),
327 ])
328 }),
329 )
330}