Skip to content

Commit 0f14afd

Browse files
authored
PRO-8803: sourcemaps fixed all the ways (#5217)
* PRO-8803: implement productionSourceMaps properly, and add productionSourceMapDir * working nicely in all scenarios * lint clean * line breaks are good * fixed dev sourcemaps too, and we can use devtool: 'source-map' everywhere because we've solved for the underlying problem we had all along * unused variables * I busted the no sourcemap case
1 parent 27451fa commit 0f14afd

6 files changed

Lines changed: 128 additions & 39 deletions

File tree

.changeset/quick-phones-care.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"apostrophe": patch
3+
---
4+
5+
- The `productionSourceMaps` option is now fully supported in both Vite and Webpack. Previously this feature did not work fully in Vite, and was not supported with Webpack. Enabling this feature completes the task of making sourcemaps fully available in the browser in production.
6+
- If you want production sourcemaps to be created but not actually uploaded for the public, you can combine `productionSourceMaps: true` with the new `productionSourceMapsDir` option to specify an alternate location where the `@apostrophecms/asset:build` task should place them. By using this option, you take responsibility for delivering the sourcemaps to their final home. Creating and/or erasing the folder between builds is also up to you. Most people will not need this option.

packages/apostrophe/modules/@apostrophecms/asset/index.js

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,14 @@ module.exports = {
8484
transform: null
8585
},
8686
// If true, the source maps will be generated in production.
87-
// This option is useful for debugging in production and is only
88-
// available when an external build module is registered (it doesn't
89-
// with the internal webpack build).
87+
// This option is useful for debugging in production
9088
productionSourceMaps: false,
89+
// By default, productionSourceMaps: true will push the source maps live right
90+
// alongside the bundle files, allowing sources to be inspected in production.
91+
// If you would like to send your production sourcemaps somewhere else,
92+
// specify productionSourceMapsDir. After that, doing something with the
93+
// sourcemaps is entirely up to you
94+
productionSourceMapsDir: null,
9195
// The configuration to control the development server and HMR when
9296
// supported. The value can be: - boolean `false`: disable the dev server and
9397
// HMR. - boolean `true`: same as `public` (default). - string `public`:
@@ -582,13 +586,12 @@ module.exports = {
582586
const deployableArtefacts = await self.copyBuildArtefacts(
583587
self.currentBuildManifest
584588
);
585-
// Copy the source maps to the bundle root.
586-
const sourceMaps = await self.copyBuildSourceMaps(self.currentBuildManifest);
587589
// Save the build manifest in the bundle root.
588590
await self.saveBuildManifest(self.currentBuildManifest);
589591

590592
// Deploy everything to the release location.
591593
// All paths are relative to the bundle root.
594+
592595
const deployFiles = [
593596
...new Set(
594597
[
@@ -598,8 +601,8 @@ module.exports = {
598601
]
599602
)
600603
];
601-
if (self.options.productionSourceMaps) {
602-
deployFiles.push(...sourceMaps || []);
604+
if (self.options.productionSourceMaps && !self.options.productionSourceMapsDir) {
605+
deployFiles.push(...bundles.map(bundle => `${bundle}.map`));
603606
}
604607

605608
await self.deploy(deployFiles);

packages/apostrophe/modules/@apostrophecms/asset/lib/build/internals.js

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const path = require('node:path');
33
const util = require('node:util');
44
const { glob } = require('../../lib/path');
55
const { getBuildExtensions, fillExtraBundles } = require('./utils');
6+
const Concat = require('concat-with-sourcemaps');
67

78
// Internal build interface.
89
module.exports = (self) => {
@@ -207,6 +208,8 @@ module.exports = (self) => {
207208
// external build module build method (see `self.build()` and
208209
// `configureBuildModule()`).
209210
async computeBuildScenes(metadata, { write = true } = {}) {
211+
212+
const needSourceMap = self.options.productionSourceMaps;
210213
const bundlePath = self.getBundleRootDir();
211214
const buildRoot = self.getBuildRootDir();
212215

@@ -266,26 +269,65 @@ module.exports = (self) => {
266269
if (!write) {
267270
return Object.keys(bundles);
268271
}
272+
269273
for (const [ target, files ] of Object.entries(bundles)) {
274+
275+
let content = null;
276+
let sourceMap = null;
277+
270278
if (!files.length) {
271279
delete bundles[target];
272280
continue;
273281
}
274-
const content = files.map(f =>
275-
fs.existsSync(f)
276-
? `\n\n/** ${path.basename(f)} **/\n\n` + fs.readFileSync(f, 'utf-8')
277-
: ''
278-
)
279-
.join('\n')
280-
.trim();
282+
283+
const filePath = path.join(bundlePath, target);
284+
285+
const fileName = filePath.split('/').at(-1);
286+
if (needSourceMap) {
287+
288+
// Concatenate in a way that preserves sitemaps
289+
const concat = new Concat(true, bundlePath, '\n');
290+
for (const file of files) {
291+
const map = `${file}.map`;
292+
// concat-with-sourcemaps does not strip old sourcemap comments for us
293+
const source = stripSourceMapComment(fs.readFileSync(file, 'utf8'));
294+
if (!fs.existsSync(map)) {
295+
concat.add(
296+
file,
297+
source
298+
);
299+
} else {
300+
concat.add(
301+
file,
302+
source,
303+
// Per docs for concat-source-maps: this one should be read as a string
304+
fs.readFileSync(map, 'utf8')
305+
);
306+
}
307+
}
308+
content = concat.content.toString('utf8') + `\n//# sourceMappingURL=${fileName}.map\n`;
309+
sourceMap = concat.sourceMap;
310+
} else {
311+
content = files.map(f => fs.readFileSync(f, 'utf-8'))
312+
.join('\n')
313+
.trim();
314+
}
281315
if (!content.trim().length) {
282316
delete bundles[target];
283317
continue;
284318
}
285-
fs.outputFileSync(
286-
path.join(bundlePath, target),
319+
fs.writeFileSync(
320+
filePath,
287321
content
288322
);
323+
if (sourceMap != null) {
324+
const dir = self.options.productionSourceMapsDir;
325+
const sourceMapPath = dir ? `${dir}/${fileName}.map` : `${filePath}.map`;
326+
fs.writeFileSync(
327+
sourceMapPath,
328+
sourceMap
329+
);
330+
}
289331
}
290332

291333
return Object.keys(bundles);
@@ -550,3 +592,8 @@ module.exports = (self) => {
550592
}
551593
};
552594
};
595+
596+
// Helper to remove existing sourceMappingURL comments
597+
function stripSourceMapComment(code) {
598+
return code.replace(/\/\/[@#]\s*sourceMappingURL=.*$/gm, '');
599+
}

packages/apostrophe/modules/@apostrophecms/asset/lib/build/task.js

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const {
1111
writeBundlesImportFiles,
1212
findNodeModulesSymlinks
1313
} = require('../webpack/utils');
14+
const Concat = require('concat-with-sourcemaps');
1415

1516
// The internal Webpack build task.
1617
module.exports = (self) => ({
@@ -544,22 +545,52 @@ module.exports = (self) => ({
544545
scenes.includes(scene);
545546
};
546547

547-
const filesContent = Object.entries(self.builds)
548+
const files = Object.entries(self.builds)
548549
.filter(([ _, options ]) => filterBuilds(options))
549-
.map(([ name ]) => {
550-
const file = `${bundleDir}/${name}-build.${fileExt}`;
551-
const readFile = (n, f) => `/* BUILD: ${n} */\n${fs.readFileSync(f, 'utf8')}`;
552-
553-
if (checkForFile) {
554-
return fs.existsSync(file)
555-
? readFile(name, file)
556-
: '';
557-
}
550+
.map(([ name ]) => `${bundleDir}/${name}-build.${fileExt}`)
551+
.filter(file => {
552+
return (!checkForFile) || fs.existsSync(file);
553+
});
554+
555+
const bundlePath = `${bundleDir}/${filePath}`;
556+
const sourceMapDir = self.options.productionSourceMapsDir || bundleDir;
557+
const sourceMapPath = `${sourceMapDir}/${filePath}.map`;
558+
559+
const needSourcemap = (process.env.NODE_ENV !== 'production') || self.options.productionSourceMaps;
560+
561+
if (!needSourcemap) {
562+
return fs.writeFileSync(bundlePath, files.map(file => fs.readFileSync(file, 'utf8')).join('\n'));
563+
}
564+
565+
// Concatenate in a way that preserves sitemaps
566+
const concat = new Concat(true, bundlePath, '\n');
567+
568+
for (const file of files) {
569+
const map = `${file}.map`;
570+
// concat-with-sourcemaps does not strip old sourcemap comments for us
571+
const source = stripSourceMapComment(fs.readFileSync(file, 'utf8'));
572+
if (!fs.existsSync(map)) {
573+
concat.add(
574+
file,
575+
source
576+
);
577+
} else {
578+
concat.add(
579+
file,
580+
source,
581+
// Per docs for concat-source-maps: this one should be read as a string
582+
fs.readFileSync(map, 'utf8')
583+
);
584+
}
585+
}
558586

559-
return readFile(name, file);
560-
}).join('\n');
587+
// Normally a production build does not include a sourcemap in the
588+
// final deliverable
561589

562-
fs.writeFileSync(`${bundleDir}/${filePath}`, filesContent);
590+
// concat-with-sourcemaps does not do this either
591+
const content = concat.content.toString('utf8') + `\n//# sourceMappingURL=${filePath}.map\n`;
592+
fs.writeFileSync(bundlePath, content);
593+
fs.writeFileSync(sourceMapPath, concat.sourceMap);
563594
}
564595

565596
// If NODE_ENV is production, this function will copy the given
@@ -596,7 +627,12 @@ module.exports = (self) => ({
596627
for (const file of files) {
597628
const src = `${bundleDir}/${file}`;
598629
await copyIn(src, `${releaseDir}/${file}`);
630+
const srcMap = `${src}.map`;
631+
if (fs.existsSync(srcMap)) {
632+
await copyIn(srcMap, `${releaseDir}/${file}.map`);
633+
}
599634
await fs.remove(src);
635+
await fs.remove(srcMap);
600636
}
601637
}
602638

@@ -864,3 +900,8 @@ module.exports = (self) => ({
864900
}
865901
}
866902
});
903+
904+
// Helper to remove existing sourceMappingURL comments
905+
function stripSourceMapComment(code) {
906+
return code.replace(/\/\/[@#]\s*sourceMappingURL=.*$/gm, '');
907+
}

packages/apostrophe/modules/@apostrophecms/asset/lib/webpack/apos/webpack.config.js

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,6 @@ module.exports = ({
2424

2525
const mode = process.env.NODE_ENV || 'development';
2626
const pnpmModulePath = apos.isPnpm ? [ path.join(apos.selfDir, '../') ] : [];
27-
const dev = (mode === 'development');
28-
// The default behavior of the security-headers module
29-
// blocks eval, and only eval-source-map currently associates
30-
// errors with the right file. With regular source-map the
31-
// results are worse than no source map. A developer who wishes
32-
// to experiment can set the devSourceMap option of the asset module
33-
const csp = apos.modules['@apostrophecms/security-headers'];
34-
const fallbackSourceMap = csp ? false : 'eval-source-map';
35-
const devtool = dev ? (apos.asset.options.devSourceMap || fallbackSourceMap) : false;
3627
const config = {
3728
performance: {
3829
hints: false
@@ -44,7 +35,7 @@ module.exports = ({
4435
optimization: {
4536
minimize: process.env.NODE_ENV === 'production'
4637
},
47-
devtool,
38+
devtool: 'source-map',
4839
output: {
4940
path: outputPath,
5041
filename: outputFilename

packages/apostrophe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"cheerio": "^1.0.0-rc.10",
7171
"chokidar": "^3.5.2",
7272
"common-tags": "^1.8.0",
73+
"concat-with-sourcemaps": "^1.1.0",
7374
"connect-mongo": "^5.1.0",
7475
"cookie-parser": "^1.4.5",
7576
"cors": "^2.8.5",

0 commit comments

Comments
 (0)