@atlaspack/optimizer-swc 2.16.9 → 2.16.10-dev-fix-sourcemaps-50acc1acf.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,10 @@
1
- import nullthrows from 'nullthrows';
2
- import {transform} from '@swc/core';
3
1
  import {Optimizer} from '@atlaspack/plugin';
4
2
  import {blobToString, stripAnsi} from '@atlaspack/utils';
5
- import SourceMap from '@atlaspack/source-map';
6
3
  import ThrowableDiagnostic, {escapeMarkdown} from '@atlaspack/diagnostic';
7
4
  import path from 'path';
8
5
 
6
+ import {minifyWithSourceMap} from './minifyWithSourceMap';
7
+
9
8
  export default new Optimizer({
10
9
  async loadConfig({config, options}) {
11
10
  let userConfig = await config.getConfigFrom(
@@ -30,25 +29,21 @@ export default new Optimizer({
30
29
  let code = await blobToString(contents);
31
30
  let result;
32
31
  try {
33
- result = await transform(code, {
34
- jsc: {
32
+ result = await minifyWithSourceMap(
33
+ code,
34
+ bundle.env.sourceMap ? (originalMap ?? null) : null,
35
+ {
35
36
  target: 'es2022',
36
- minify: {
37
- mangle: true,
38
- compress: true,
39
- // @ts-expect-error TS2698
40
- ...userConfig,
41
- toplevel:
42
- bundle.env.outputFormat === 'esmodule' ||
43
- bundle.env.outputFormat === 'commonjs',
44
- module: bundle.env.outputFormat === 'esmodule',
45
- },
37
+ mangle: true,
38
+ compress: true,
39
+ toplevel:
40
+ bundle.env.outputFormat === 'esmodule' ||
41
+ bundle.env.outputFormat === 'commonjs',
42
+ module: bundle.env.outputFormat === 'esmodule',
43
+ userConfig: userConfig as Record<string, unknown> | undefined,
44
+ projectRoot: options.projectRoot,
46
45
  },
47
- minify: true,
48
- sourceMaps: !!bundle.env.sourceMap,
49
- configFile: false,
50
- swcrc: false,
51
- });
46
+ );
52
47
  } catch (err: any) {
53
48
  // SWC doesn't give us nice error objects, so we need to parse the message.
54
49
  let message = escapeMarkdown(
@@ -107,15 +102,9 @@ export default new Optimizer({
107
102
  throw err;
108
103
  }
109
104
 
110
- let sourceMap = null;
111
- let minifiedContents: string = nullthrows(result.code);
112
- let resultMap = result.map;
113
- if (resultMap) {
114
- sourceMap = new SourceMap(options.projectRoot);
115
- sourceMap.addVLQMap(JSON.parse(resultMap));
116
- if (originalMap) {
117
- sourceMap.extends(originalMap);
118
- }
105
+ let minifiedContents: string = result.code;
106
+ const sourceMap = result.map;
107
+ if (sourceMap) {
119
108
  let sourcemapReference = await getSourceMapReference(sourceMap);
120
109
  if (sourcemapReference) {
121
110
  minifiedContents += `\n//# sourceMappingURL=${sourcemapReference}\n`;
@@ -0,0 +1,104 @@
1
+ import nullthrows from 'nullthrows';
2
+ import {transform} from '@swc/core';
3
+ import SourceMap from '@atlaspack/source-map';
4
+
5
+ export type MinifyOptions = {
6
+ /** ES target for SWC. */
7
+ target?: string;
8
+ /** Whether to enable name mangling. */
9
+ mangle?: boolean;
10
+ /** Whether to enable compression. */
11
+ compress?: boolean;
12
+ /** Mark output as a top-level module (esmodule/commonjs). */
13
+ toplevel?: boolean;
14
+ /** Treat the input as an ES module. */
15
+ module?: boolean;
16
+ /** Extra terser-style overrides forwarded into `jsc.minify`. */
17
+ userConfig?: Record<string, unknown>;
18
+ /** Project root used when constructing the SourceMap instance. */
19
+ projectRoot?: string;
20
+ };
21
+
22
+ export type MinifyResult = {
23
+ code: string;
24
+ map: SourceMap | null;
25
+ };
26
+
27
+ /**
28
+ * Minify `code` with SWC and produce a SourceMap whose `original*` positions
29
+ * point back to the sources described by `originalMap`.
30
+ *
31
+ * When `originalMap` is provided, we hand its VLQ-encoded form to SWC via the
32
+ * `inputSourceMap` option. SWC then composes its own (minified -> code) map
33
+ * with the input (code -> original sources) map per-token during
34
+ * minification, producing a final map that points straight back to the
35
+ * original sources.
36
+ *
37
+ * This is much more accurate than composing the maps *after* the fact with
38
+ * `SourceMap.extends(originalMap)`. The post-hoc `extends` approach calls
39
+ * `findClosestMapping`, which snaps each minified token to the previous
40
+ * mapping in the input map. When the input map has many mappings on a single
41
+ * generated line (which is the norm – every asset's tokens get packed onto
42
+ * the same generated line by the packager), "the previous mapping" is often
43
+ * the wrong source/line entirely, producing systematically misaligned maps.
44
+ */
45
+ export async function minifyWithSourceMap(
46
+ code: string,
47
+ originalMap: SourceMap | null,
48
+ options: MinifyOptions = {},
49
+ ): Promise<MinifyResult> {
50
+ const {
51
+ target = 'es2022',
52
+ mangle = true,
53
+ compress = true,
54
+ toplevel = false,
55
+ module: isModule = false,
56
+ userConfig = {},
57
+ projectRoot = '/',
58
+ } = options;
59
+
60
+ let inputSourceMap: string | undefined;
61
+ if (originalMap) {
62
+ // Hand the input map to swc as `inputSourceMap` so swc composes the
63
+ // (minified -> code) and (code -> original sources) maps together
64
+ // per-token during minification. This is more accurate than calling
65
+ // `SourceMap.extends(originalMap)` after the fact, which uses
66
+ // `findClosestMapping` and snaps each minified token to the *nearest*
67
+ // mapping in the input map – losing column precision and sometimes
68
+ // mis-attributing tokens across asset boundaries when the input map
69
+ // has gaps. It also leaks swc's placeholder `<anon>` source into the
70
+ // resulting map's `sources` array.
71
+ // swc requires `version: 3` to parse the input map. The atlaspack
72
+ // SourceMap.toVLQ() helper doesn't include it, so we add it here.
73
+ inputSourceMap = JSON.stringify({version: 3, ...originalMap.toVLQ()});
74
+ }
75
+
76
+ const result = await transform(code, {
77
+ jsc: {
78
+ target: target as any,
79
+ minify: {
80
+ mangle,
81
+ compress,
82
+ ...(userConfig as object),
83
+ toplevel,
84
+ module: isModule,
85
+ } as any,
86
+ },
87
+ minify: true,
88
+ sourceMaps: true,
89
+ inputSourceMap,
90
+ configFile: false,
91
+ swcrc: false,
92
+ });
93
+
94
+ const minifiedCode: string = nullthrows(result.code);
95
+ if (!result.map) {
96
+ return {code: minifiedCode, map: null};
97
+ }
98
+
99
+ // With `inputSourceMap` provided, swc has already composed the maps;
100
+ // the result already references the original sources directly.
101
+ const sourceMap = new SourceMap(projectRoot);
102
+ sourceMap.addVLQMap(JSON.parse(result.map));
103
+ return {code: minifiedCode, map: sourceMap};
104
+ }
@@ -0,0 +1,370 @@
1
+ import assert from 'assert';
2
+ import SourceMap from '@atlaspack/source-map';
3
+
4
+ import {minifyWithSourceMap} from '../src/minifyWithSourceMap';
5
+
6
+ const PROJECT_ROOT = '/project';
7
+
8
+ /**
9
+ * Walk the bundle for a set of literal strings, and for each one, resolve its
10
+ * position via the source map and check that the resolved original source line
11
+ * actually contains that string. This is the same "string-literal probe"
12
+ * technique that exposed the real-world misalignment.
13
+ *
14
+ * Returns { matched, total, mismatches } so tests can assert specific bounds.
15
+ */
16
+ function probeStringLiterals(opts: {
17
+ bundle: string;
18
+ map: SourceMap;
19
+ /**
20
+ * Map of source name → expected (line, col) of each probe within that source.
21
+ * The probe is considered "correctly aligned" when the source map's reverse
22
+ * lookup returns the exact (source, line, col) where the probe actually
23
+ * lives.
24
+ */
25
+ expected: Record<
26
+ string,
27
+ {
28
+ source: string;
29
+ line: number;
30
+ column: number;
31
+ }
32
+ >;
33
+ probes: string[];
34
+ }): Promise<{
35
+ matched: number;
36
+ total: number;
37
+ mismatches: Array<{
38
+ text: string;
39
+ bundleLine: number;
40
+ bundleCol: number;
41
+ resolved: {source?: string; line?: number; column?: number} | null;
42
+ expected: {source: string; line: number; column: number};
43
+ }>;
44
+ }> {
45
+ const bundleLines = opts.bundle.split('\n');
46
+ let matched = 0;
47
+ const mismatches: Array<any> = [];
48
+
49
+ for (const probe of opts.probes) {
50
+ const expected = opts.expected[probe];
51
+ let found = false;
52
+ for (let l = 0; l < bundleLines.length; l++) {
53
+ const re = new RegExp(`['"\`]${probe}['"\`]`);
54
+ const m = bundleLines[l].match(re);
55
+ if (!m) continue;
56
+ const idx = (m.index ?? 0) + 1; // step past the quote so we land on the literal text
57
+ const resolved = opts.map.findClosestMapping(l + 1, idx);
58
+ const resolvedSource = resolved?.source;
59
+ const resolvedLine = resolved?.original?.line;
60
+ const resolvedCol = resolved?.original?.column;
61
+ // We require the resolved (source, line, column) to match the
62
+ // expected position exactly. The buggy `extends()` approach loses
63
+ // column precision (it snaps to the nearest input mapping), so this
64
+ // check is what surfaces the bug.
65
+ const ok =
66
+ resolvedSource === expected.source &&
67
+ resolvedLine === expected.line &&
68
+ resolvedCol === expected.column;
69
+ if (ok) {
70
+ matched++;
71
+ } else {
72
+ mismatches.push({
73
+ text: probe,
74
+ bundleLine: l + 1,
75
+ bundleCol: idx,
76
+ resolved: {
77
+ source: resolvedSource,
78
+ line: resolvedLine,
79
+ column: resolvedCol,
80
+ },
81
+ expected,
82
+ });
83
+ }
84
+ found = true;
85
+ break;
86
+ }
87
+ if (!found) {
88
+ mismatches.push({
89
+ text: probe,
90
+ bundleLine: -1,
91
+ bundleCol: -1,
92
+ resolved: null,
93
+ expected,
94
+ });
95
+ }
96
+ }
97
+
98
+ return {matched, total: opts.probes.length, mismatches};
99
+ }
100
+
101
+ describe('minifyWithSourceMap', () => {
102
+ it('passes through when source map composition is not needed', async () => {
103
+ const code = 'console.log("hello");';
104
+ const out = await minifyWithSourceMap(code, null, {
105
+ projectRoot: PROJECT_ROOT,
106
+ });
107
+ assert.ok(out.code.includes('"hello"'));
108
+ });
109
+
110
+ it('forwards the input map to swc as `inputSourceMap` (so map composition happens during minify, not as a post-hoc snap)', async () => {
111
+ // This is a regression test for a real Confluence production bug:
112
+ // `SwcOptimizer` used to compose its (minified -> code) map with the
113
+ // packager's (code -> original sources) map by calling
114
+ // `SourceMap.extends(originalMap)` *after* swc had finished minifying.
115
+ //
116
+ // That post-hoc composition uses `findClosestMapping`, which:
117
+ // - snaps to the previous mapping when the looked-up position has no
118
+ // exact mapping (losing column precision), and
119
+ // - can snap *across asset boundaries* when the input map has gaps
120
+ // (mis-attributing a token to the wrong source file).
121
+ //
122
+ // String-literal probes of real Confluence bundles showed 30–65 % of
123
+ // mappings resolving to a source line that did not actually contain
124
+ // the literal – evidence the maps were systematically wrong.
125
+ //
126
+ // The fix is to hand the input map to swc via the `inputSourceMap`
127
+ // option, so swc composes the maps per-token natively during
128
+ // minification.
129
+ //
130
+ // We can't easily reproduce the bug end-to-end here with synthetic data
131
+ // (the test inputs are too regular for `findClosestMapping`'s snap-back
132
+ // to produce visibly wrong output), so the test focuses on the
133
+ // observable contract: when an input map is provided, the resulting
134
+ // map's `sources` array must contain the original sources, and the
135
+ // resulting map's mappings must successfully reverse-resolve a probe
136
+ // back to one of those original sources.
137
+
138
+ const source = [
139
+ 'console.log("alpha");',
140
+ 'console.log("beta");',
141
+ 'console.log("gamma");',
142
+ ].join('\n');
143
+ const sourceName = 'src/widget.js';
144
+
145
+ const originalMap = new SourceMap(PROJECT_ROOT);
146
+ originalMap.addIndexedMappings([
147
+ {
148
+ generated: {line: 1, column: 0},
149
+ original: {line: 1, column: 0},
150
+ source: sourceName,
151
+ },
152
+ {
153
+ generated: {line: 2, column: 0},
154
+ original: {line: 2, column: 0},
155
+ source: sourceName,
156
+ },
157
+ {
158
+ generated: {line: 3, column: 0},
159
+ original: {line: 3, column: 0},
160
+ source: sourceName,
161
+ },
162
+ ]);
163
+ originalMap.setSourceContent(sourceName, source);
164
+
165
+ const out = await minifyWithSourceMap(source, originalMap, {
166
+ projectRoot: PROJECT_ROOT,
167
+ mangle: false,
168
+ compress: true,
169
+ });
170
+
171
+ assert.ok(out.map, 'expected a source map to be produced');
172
+
173
+ const vlq = out.map!.toVLQ();
174
+ // The output map must reference the ORIGINAL source file. The post-hoc
175
+ // `extends()` path leaks `<anon>` (swc's placeholder source name for the
176
+ // input it was given) into the sources array, alongside the real source
177
+ // name from the input map. With `inputSourceMap` swc knows the real
178
+ // source names up-front and emits only those.
179
+ assert.ok(
180
+ vlq.sources.includes(sourceName),
181
+ `expected output map.sources to include ${sourceName}, got ${JSON.stringify(vlq.sources)}`,
182
+ );
183
+ assert.ok(
184
+ !vlq.sources.some((s) => s === '<anon>' || s === '<source>'),
185
+ `output map.sources must not contain swc placeholder source names, got ${JSON.stringify(vlq.sources)}`,
186
+ );
187
+
188
+ // Reverse-resolve each probe and check it lands on src/widget.js at
189
+ // the right line.
190
+ for (const probe of ['alpha', 'beta', 'gamma']) {
191
+ const probeIdx = out.code.indexOf(`"${probe}"`);
192
+ assert.ok(probeIdx >= 0, `probe ${probe} should be in minified code`);
193
+ const resolved = out.map!.findClosestMapping(1, probeIdx + 1);
194
+ assert.strictEqual(
195
+ resolved?.source,
196
+ sourceName,
197
+ `probe ${probe} should resolve to ${sourceName}, got ${resolved?.source}`,
198
+ );
199
+ // Each probe lives on its own original line.
200
+ const expectedLine = ['alpha', 'beta', 'gamma'].indexOf(probe) + 1;
201
+ assert.strictEqual(
202
+ resolved?.original?.line,
203
+ expectedLine,
204
+ `probe ${probe} should resolve to line ${expectedLine}, got ${resolved?.original?.line}`,
205
+ );
206
+ }
207
+ });
208
+
209
+ it('produces a source map that correctly resolves string literals back to their original lines', async () => {
210
+ // Three source modules, each with several "log calls" on a single line.
211
+ // This mirrors real-world bundles where the packager produces source maps
212
+ // with multiple mappings per generated line: each asset's tokens get
213
+ // packed onto one line of the bundle and several mappings share that
214
+ // line.
215
+ //
216
+ // The optimizer must compose its (minified -> bundle) map with the input
217
+ // (bundle -> original sources) map per-token. The buggy implementation
218
+ // uses `SourceMap.extends(originalMap)`, which calls `findClosestMapping`
219
+ // on the original map – and that snaps to the previous mapping in the
220
+ // map. When the input map has gaps (e.g. an asset's prologue line has no
221
+ // mapping, or the next asset's first line has no leading mapping), the
222
+ // snap goes backwards across an asset boundary and the minified token
223
+ // ends up attributed to the wrong source file entirely.
224
+ type Mod = {sourceName: string; source: string; probes: string[]};
225
+
226
+ const buildMod = (sourceName: string, prefix: string): Mod => {
227
+ // 4 lines, each with 5 distinct console.log statements. Multiple
228
+ // mappings per generated line at the column of each log call.
229
+ const lines: string[] = [];
230
+ const probes: string[] = [];
231
+ for (let l = 1; l <= 4; l++) {
232
+ const calls: string[] = [];
233
+ for (let c = 1; c <= 5; c++) {
234
+ const probe = `${prefix}_l${l}c${c}`;
235
+ probes.push(probe);
236
+ calls.push(`console.log("${probe}");`);
237
+ }
238
+ lines.push(calls.join(' '));
239
+ }
240
+ return {sourceName, source: lines.join('\n'), probes};
241
+ };
242
+
243
+ const mods: Mod[] = [
244
+ buildMod('src/a.js', 'A'),
245
+ buildMod('src/b.js', 'B'),
246
+ buildMod('src/c.js', 'C'),
247
+ ];
248
+
249
+ // Build the bundle by concatenating each module's source *onto a single
250
+ // line*, separated by a `;` – the way scope-hoisting bundlers often do
251
+ // it. The mappings still reference the original module's per-line, per-
252
+ // call positions, so the input map ends up with all mappings on
253
+ // generated line 1, but the column ranges interleave across the three
254
+ // sources.
255
+ //
256
+ // This is the structure that exposes the bug: when the minifier later
257
+ // emits a mapping at some column, the buggy `extends()` snaps to the
258
+ // closest mapping by *column*, which – with sparse mappings spanning
259
+ // multiple source files on a single line – often lands on the
260
+ // *previous source file entirely*.
261
+ const indexedMappings: Array<{
262
+ generated: {line: number; column: number};
263
+ original: {line: number; column: number};
264
+ source: string;
265
+ name?: string;
266
+ }> = [];
267
+
268
+ // Concatenate each module onto separate bundle lines, separating
269
+ // assets with a blank "boundary" line that has NO mapping in the input
270
+ // map. Real packagers insert such gaps (e.g. blank lines between
271
+ // assets, asset wrappers like `var $abc$exports = ...` that aren't
272
+ // mapped back to a source).
273
+ //
274
+ // This is what reproduces the production bug: when SWC emits a mapping
275
+ // at minified column X claiming it came from the gap line, the buggy
276
+ // `extends()` approach snaps to the previous mapping on a different
277
+ // bundle line entirely, mis-attributing the token.
278
+ let lineOffset = 0;
279
+ const bundleParts: string[] = [];
280
+ for (const mod of mods) {
281
+ bundleParts.push(mod.source);
282
+ const lines = mod.source.split('\n');
283
+ for (let l = 0; l < lines.length; l++) {
284
+ const line = lines[l];
285
+ const re = /console\.log\("[^"]+"\);?/g;
286
+ let m;
287
+ while ((m = re.exec(line))) {
288
+ indexedMappings.push({
289
+ generated: {line: l + 1 + lineOffset, column: m.index},
290
+ original: {line: l + 1, column: m.index},
291
+ source: mod.sourceName,
292
+ });
293
+ }
294
+ }
295
+ // +1 for the joining blank line below
296
+ lineOffset += lines.length + 1;
297
+ }
298
+ // The blank lines between assets have NO mapping – the input map has a
299
+ // gap that the buggy extends() will snap across.
300
+ const bundleInput = bundleParts.join('\n\n');
301
+
302
+ const originalMap = new SourceMap(PROJECT_ROOT);
303
+ originalMap.addIndexedMappings(indexedMappings);
304
+ for (const mod of mods) {
305
+ originalMap.setSourceContent(mod.sourceName, mod.source);
306
+ }
307
+
308
+ const out = await minifyWithSourceMap(bundleInput, originalMap, {
309
+ projectRoot: PROJECT_ROOT,
310
+ mangle: false,
311
+ compress: true,
312
+ });
313
+
314
+ assert.ok(out.map, 'expected a source map to be produced');
315
+
316
+ // Each probe lives in a `console.log("<probe>");` statement at a known
317
+ // (source, line, column) of its original module. Since the input map
318
+ // has a mapping at the START of each `console.log` call, we expect the
319
+ // post-minify map to reverse-resolve each probe to:
320
+ // - the right source file
321
+ // - the right line within that source
322
+ // - the column where the matching `console.log` call starts
323
+ const allProbes = mods.flatMap((m) => m.probes);
324
+ const expected: Record<
325
+ string,
326
+ {source: string; line: number; column: number}
327
+ > = {};
328
+ for (const mod of mods) {
329
+ const lines = mod.source.split('\n');
330
+ for (let l = 0; l < lines.length; l++) {
331
+ const line = lines[l];
332
+ for (const probe of mod.probes) {
333
+ const idx = line.indexOf(`console.log("${probe}")`);
334
+ if (idx >= 0) {
335
+ expected[probe] = {
336
+ source: mod.sourceName,
337
+ line: l + 1,
338
+ column: idx,
339
+ };
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ const result = probeStringLiterals({
346
+ bundle: out.code,
347
+ map: out.map!,
348
+ expected,
349
+ probes: allProbes,
350
+ });
351
+
352
+ if (result.matched !== result.total) {
353
+ const summary = result.mismatches
354
+ .slice(0, 8)
355
+ .map(
356
+ (m) =>
357
+ ` - ${m.text} @ bundle ${m.bundleLine}:${m.bundleCol} -> ` +
358
+ `resolved=${JSON.stringify(m.resolved)} expected=${JSON.stringify(m.expected)}`,
359
+ )
360
+ .join('\n');
361
+ throw new Error(
362
+ `Source map misaligned: only ${result.matched}/${result.total} string literals ` +
363
+ `resolved to the exact original (source, line, column) where they live.\n` +
364
+ `First mismatches:\n${summary}`,
365
+ );
366
+ }
367
+
368
+ assert.strictEqual(result.matched, result.total);
369
+ });
370
+ });