[Flight] Enable Server Action Source Maps in flight-esm Fixture (#30763)
Stacked on #30758 and #30755. This is copy paste from #30755 into the ESM package. We use the `webpack-sources` package for the source map utility but it's not actually dependent on Webpack itself. Could probably inline it in the build.
Sebastian Markbåge committed
Aug 22, 2024 at 12:35 UTC
97e2ce6a003db070d1d14ca25ac4b30e1df4a8ce
4 files changed
+372
-44
fixtures/flight-esm/package.json
+3
-2
@@ -13,14 +13,15 @@
13
"prompts": "^2.4.2",
14
"react": "experimental",
15
"react-dom": "experimental",
16
- "undici": "^5.20.0"
16
+ "undici": "^5.20.0",
17
+ "webpack-sources": "^3.2.0"
18
},
19
"scripts": {
20
"predev": "cp -r ../../build/oss-experimental/* ./node_modules/",
21
"prestart": "cp -r ../../build/oss-experimental/* ./node_modules/",
22
"dev": "concurrently \"npm run dev:region\" \"npm run dev:global\"",
23
"dev:global": "NODE_ENV=development BUILD_PATH=dist node server/global",
23
- "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --experimental-loader ./loader/region.js --conditions=react-server server/region",
24
+ "dev:region": "NODE_ENV=development BUILD_PATH=dist nodemon --watch src --watch dist -- --enable-source-maps --experimental-loader ./loader/region.js --conditions=react-server server/region",
25
"start": "concurrently \"npm run start:region\" \"npm run start:global\"",
26
"start:global": "NODE_ENV=production node server/global",
27
"start:region": "NODE_ENV=production node --experimental-loader ./loader/region.js --conditions=react-server server/region"
fixtures/flight-esm/yarn.lock
+5
@@ -755,6 +755,11 @@ vary@~1.1.2:
755
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
756
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
757
758
+webpack-sources@^3.2.0:
759
+ version "3.2.3"
760
+ resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
761
+ integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
762
+
763
wrap-ansi@^7.0.0:
764
version "7.0.0"
765
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
packages/react-server-dom-esm/package.json
+2
-1
@@ -58,6 +58,7 @@
58
"react-dom": "^19.0.0"
59
},
60
"dependencies": {
61
- "acorn-loose": "^8.3.0"
61
+ "acorn-loose": "^8.3.0",
62
+ "webpack-sources": "^3.2.0"
63
}
64
}
packages/react-server-dom-esm/src/ReactFlightESMNodeLoader.js
+362
-41
@@ -9,6 +9,9 @@
9
10
import * as acorn from 'acorn-loose';
11
12
+import readMappings from 'webpack-sources/lib/helpers/readMappings.js';
13
+import createMappingsSerializer from 'webpack-sources/lib/helpers/createMappingsSerializer.js';
14
+
15
type ResolveContext = {
16
conditions: Array<string>,
17
parentURL: string | void,
@@ -95,45 +98,102 @@ export async function getSource(
98
return defaultGetSource(url, context, defaultGetSource);
99
}
100
98
-function addLocalExportedNames(names: Map<string, string>, node: any) {
101
+type ExportedEntry = {
102
+ localName: string,
103
+ exportedName: string,
104
+ type: null | string,
105
+ loc: {
106
+ start: {line: number, column: number},
107
+ end: {line: number, column: number},
108
+ },
109
+ originalLine: number,
110
+ originalColumn: number,
111
+ originalSource: number,
112
+ nameIndex: number,
113
+};
114
+
115
+function addExportedEntry(
116
+ exportedEntries: Array<ExportedEntry>,
117
+ localNames: Set<string>,
118
+ localName: string,
119
+ exportedName: string,
120
+ type: null | 'function',
121
+ loc: {
122
+ start: {line: number, column: number},
123
+ end: {line: number, column: number},
124
+ },
125
+) {
126
+ if (localNames.has(localName)) {
127
+ // If the same local name is exported more than once, we only need one of the names.
128
+ return;
129
+ }
130
+ exportedEntries.push({
131
+ localName,
132
+ exportedName,
133
+ type,
134
+ loc,
135
+ originalLine: -1,
136
+ originalColumn: -1,
137
+ originalSource: -1,
138
+ nameIndex: -1,
139
+ });
140
+}
141
+
142
+function addLocalExportedNames(
143
+ exportedEntries: Array<ExportedEntry>,
144
+ localNames: Set<string>,
145
+ node: any,
146
+) {
147
switch (node.type) {
148
case 'Identifier':
101
- names.set(node.name, node.name);
149
+ addExportedEntry(
150
+ exportedEntries,
151
+ localNames,
152
+ node.name,
153
+ node.name,
154
+ null,
155
+ node.loc,
156
+ );
157
return;
158
case 'ObjectPattern':
159
for (let i = 0; i < node.properties.length; i++)
105
- addLocalExportedNames(names, node.properties[i]);
160
+ addLocalExportedNames(exportedEntries, localNames, node.properties[i]);
161
return;
162
case 'ArrayPattern':
163
for (let i = 0; i < node.elements.length; i++) {
164
const element = node.elements[i];
110
- if (element) addLocalExportedNames(names, element);
165
+ if (element)
166
+ addLocalExportedNames(exportedEntries, localNames, element);
167
}
168
return;
169
case 'Property':
114
- addLocalExportedNames(names, node.value);
170
+ addLocalExportedNames(exportedEntries, localNames, node.value);
171
return;
172
case 'AssignmentPattern':
117
- addLocalExportedNames(names, node.left);
173
+ addLocalExportedNames(exportedEntries, localNames, node.left);
174
return;
175
case 'RestElement':
120
- addLocalExportedNames(names, node.argument);
176
+ addLocalExportedNames(exportedEntries, localNames, node.argument);
177
return;
178
case 'ParenthesizedExpression':
123
- addLocalExportedNames(names, node.expression);
179
+ addLocalExportedNames(exportedEntries, localNames, node.expression);
180
return;
181
}
182
}
183
184
function transformServerModule(
185
source: string,
130
- body: any,
186
+ program: any,
187
url: string,
188
+ sourceMap: any,
189
loader: LoadFunction,
190
): string {
134
- // If the same local name is exported more than once, we only need one of the names.
135
- const localNames: Map<string, string> = new Map();
136
- const localTypes: Map<string, string> = new Map();
191
+ const body = program.body;
192
+
193
+ // This entry list needs to be in source location order.
194
+ const exportedEntries: Array<ExportedEntry> = [];
195
+ // Dedupe set.
196
+ const localNames: Set<string> = new Set();
197
198
for (let i = 0; i < body.length; i++) {
199
const node = body[i];
@@ -143,11 +203,24 @@ function transformServerModule(
203
break;
204
case 'ExportDefaultDeclaration':
205
if (node.declaration.type === 'Identifier') {
146
- localNames.set(node.declaration.name, 'default');
206
+ addExportedEntry(
207
+ exportedEntries,
208
+ localNames,
209
+ node.declaration.name,
210
+ 'default',
211
+ null,
212
+ node.declaration.loc,
213
+ );
214
} else if (node.declaration.type === 'FunctionDeclaration') {
215
if (node.declaration.id) {
149
- localNames.set(node.declaration.id.name, 'default');
150
- localTypes.set(node.declaration.id.name, 'function');
216
+ addExportedEntry(
217
+ exportedEntries,
218
+ localNames,
219
+ node.declaration.id.name,
220
+ 'default',
221
+ 'function',
222
+ node.declaration.id.loc,
223
+ );
224
} else {
225
// TODO: This needs to be rewritten inline because it doesn't have a local name.
226
}
@@ -158,41 +231,230 @@ function transformServerModule(
231
if (node.declaration.type === 'VariableDeclaration') {
232
const declarations = node.declaration.declarations;
233
for (let j = 0; j < declarations.length; j++) {
161
- addLocalExportedNames(localNames, declarations[j].id);
234
+ addLocalExportedNames(
235
+ exportedEntries,
236
+ localNames,
237
+ declarations[j].id,
238
+ );
239
}
240
} else {
241
const name = node.declaration.id.name;
165
- localNames.set(name, name);
166
- if (node.declaration.type === 'FunctionDeclaration') {
167
- localTypes.set(name, 'function');
168
- }
242
+ addExportedEntry(
243
+ exportedEntries,
244
+ localNames,
245
+ name,
246
+ name,
247
+
248
+ node.declaration.type === 'FunctionDeclaration'
249
+ ? 'function'
250
+ : null,
251
+ node.declaration.id.loc,
252
+ );
253
}
254
}
255
if (node.specifiers) {
256
const specifiers = node.specifiers;
257
for (let j = 0; j < specifiers.length; j++) {
258
const specifier = specifiers[j];
175
- localNames.set(specifier.local.name, specifier.exported.name);
259
+ addExportedEntry(
260
+ exportedEntries,
261
+ localNames,
262
+ specifier.local.name,
263
+ specifier.exported.name,
264
+ null,
265
+ specifier.local.loc,
266
+ );
267
}
268
}
269
continue;
270
}
271
}
181
- if (localNames.size === 0) {
182
- return source;
183
- }
184
- let newSrc = source + '\n\n;';
185
- newSrc +=
186
- 'import {registerServerReference} from "react-server-dom-esm/server";\n';
187
- localNames.forEach(function (exported, local) {
188
- if (localTypes.get(local) !== 'function') {
189
- // We first check if the export is a function and if so annotate it.
190
- newSrc += 'if (typeof ' + local + ' === "function") ';
272
+
273
+ let mappings =
274
+ sourceMap && typeof sourceMap.mappings === 'string'
275
+ ? sourceMap.mappings
276
+ : '';
277
+ let newSrc = source;
278
+
279
+ if (exportedEntries.length > 0) {
280
+ let lastSourceIndex = 0;
281
+ let lastOriginalLine = 0;
282
+ let lastOriginalColumn = 0;
283
+ let lastNameIndex = 0;
284
+ let sourceLineCount = 0;
285
+ let lastMappedLine = 0;
286
+
287
+ if (sourceMap) {
288
+ // We iterate source mapping entries and our matched exports in parallel to source map
289
+ // them to their original location.
290
+ let nextEntryIdx = 0;
291
+ let nextEntryLine = exportedEntries[nextEntryIdx].loc.start.line;
292
+ let nextEntryColumn = exportedEntries[nextEntryIdx].loc.start.column;
293
+ readMappings(
294
+ mappings,
295
+ (
296
+ generatedLine: number,
297
+ generatedColumn: number,
298
+ sourceIndex: number,
299
+ originalLine: number,
300
+ originalColumn: number,
301
+ nameIndex: number,
302
+ ) => {
303
+ if (
304
+ generatedLine > nextEntryLine ||
305
+ (generatedLine === nextEntryLine &&
306
+ generatedColumn > nextEntryColumn)
307
+ ) {
308
+ // We're past the entry which means that the best match we have is the previous entry.
309
+ if (lastMappedLine === nextEntryLine) {
310
+ // Match
311
+ exportedEntries[nextEntryIdx].originalLine = lastOriginalLine;
312
+ exportedEntries[nextEntryIdx].originalColumn = lastOriginalColumn;
313
+ exportedEntries[nextEntryIdx].originalSource = lastSourceIndex;
314
+ exportedEntries[nextEntryIdx].nameIndex = lastNameIndex;
315
+ } else {
316
+ // Skip if we didn't have any mappings on the exported line.
317
+ }
318
+ nextEntryIdx++;
319
+ if (nextEntryIdx < exportedEntries.length) {
320
+ nextEntryLine = exportedEntries[nextEntryIdx].loc.start.line;
321
+ nextEntryColumn = exportedEntries[nextEntryIdx].loc.start.column;
322
+ } else {
323
+ nextEntryLine = -1;
324
+ nextEntryColumn = -1;
325
+ }
326
+ }
327
+ lastMappedLine = generatedLine;
328
+ if (sourceIndex > -1) {
329
+ lastSourceIndex = sourceIndex;
330
+ }
331
+ if (originalLine > -1) {
332
+ lastOriginalLine = originalLine;
333
+ }
334
+ if (originalColumn > -1) {
335
+ lastOriginalColumn = originalColumn;
336
+ }
337
+ if (nameIndex > -1) {
338
+ lastNameIndex = nameIndex;
339
+ }
340
+ },
341
+ );
342
+ if (nextEntryIdx < exportedEntries.length) {
343
+ if (lastMappedLine === nextEntryLine) {
344
+ // Match
345
+ exportedEntries[nextEntryIdx].originalLine = lastOriginalLine;
346
+ exportedEntries[nextEntryIdx].originalColumn = lastOriginalColumn;
347
+ exportedEntries[nextEntryIdx].originalSource = lastSourceIndex;
348
+ exportedEntries[nextEntryIdx].nameIndex = lastNameIndex;
349
+ }
350
+ }
351
+
352
+ for (
353
+ let lastIdx = mappings.length - 1;
354
+ lastIdx >= 0 && mappings[lastIdx] === ';';
355
+ lastIdx--
356
+ ) {
357
+ // If the last mapped lines don't contain any segments, we don't get a callback from readMappings
358
+ // so we need to pad the number of mapped lines, with one for each empty line.
359
+ lastMappedLine++;
360
+ }
361
+
362
+ sourceLineCount = program.loc.end.line;
363
+ if (sourceLineCount < lastMappedLine) {
364
+ throw new Error(
365
+ 'The source map has more mappings than there are lines.',
366
+ );
367
+ }
368
+ // If the original source string had more lines than there are mappings in the source map.
369
+ // Add some extra padding of unmapped lines so that any lines that we add line up.
370
+ for (
371
+ let extraLines = sourceLineCount - lastMappedLine;
372
+ extraLines > 0;
373
+ extraLines--
374
+ ) {
375
+ mappings += ';';
376
+ }
377
+ } else {
378
+ // If a file doesn't have a source map then we generate a blank source map that just
379
+ // contains the original content and segments pointing to the original lines.
380
+ sourceLineCount = 1;
381
+ let idx = -1;
382
+ while ((idx = source.indexOf('\n', idx + 1)) !== -1) {
383
+ sourceLineCount++;
384
+ }
385
+ mappings = 'AAAA' + ';AACA'.repeat(sourceLineCount - 1);
386
+ sourceMap = {
387
+ version: 3,
388
+ sources: [url],
389
+ sourcesContent: [source],
390
+ mappings: mappings,
391
+ sourceRoot: '',
392
+ };
393
+ lastSourceIndex = 0;
394
+ lastOriginalLine = sourceLineCount;
395
+ lastOriginalColumn = 0;
396
+ lastNameIndex = -1;
397
+ lastMappedLine = sourceLineCount;
398
+
399
+ for (let i = 0; i < exportedEntries.length; i++) {
400
+ // Point each entry to original location.
401
+ const entry = exportedEntries[i];
402
+ entry.originalSource = 0;
403
+ entry.originalLine = entry.loc.start.line;
404
+ // We use column zero since we do the short-hand line-only source maps above.
405
+ entry.originalColumn = 0; // entry.loc.start.column;
406
+ }
407
}
192
- newSrc += 'registerServerReference(' + local + ',';
193
- newSrc += JSON.stringify(url) + ',';
194
- newSrc += JSON.stringify(exported) + ');\n';
195
- });
408
+
409
+ newSrc += '\n\n;';
410
+ newSrc +=
411
+ 'import {registerServerReference} from "react-server-dom-esm/server";\n';
412
+ if (mappings) {
413
+ mappings += ';;';
414
+ }
415
+
416
+ const createMapping = createMappingsSerializer();
417
+
418
+ // Create an empty mapping pointing to where we last left off to reset the counters.
419
+ let generatedLine = 1;
420
+ createMapping(
421
+ generatedLine,
422
+ 0,
423
+ lastSourceIndex,
424
+ lastOriginalLine,
425
+ lastOriginalColumn,
426
+ lastNameIndex,
427
+ );
428
+ for (let i = 0; i < exportedEntries.length; i++) {
429
+ const entry = exportedEntries[i];
430
+ generatedLine++;
431
+ if (entry.type !== 'function') {
432
+ // We first check if the export is a function and if so annotate it.
433
+ newSrc += 'if (typeof ' + entry.localName + ' === "function") ';
434
+ }
435
+ newSrc += 'registerServerReference(' + entry.localName + ',';
436
+ newSrc += JSON.stringify(url) + ',';
437
+ newSrc += JSON.stringify(entry.exportedName) + ');\n';
438
+
439
+ mappings += createMapping(
440
+ generatedLine,
441
+ 0,
442
+ entry.originalSource,
443
+ entry.originalLine,
444
+ entry.originalColumn,
445
+ entry.nameIndex,
446
+ );
447
+ }
448
+ }
449
+
450
+ if (sourceMap) {
451
+ // Override with an new mappings and serialize an inline source map.
452
+ sourceMap.mappings = mappings;
453
+ newSrc +=
454
+ '//# sourceMappingURL=data:application/json;charset=utf-8;base64,' +
455
+ Buffer.from(JSON.stringify(sourceMap)).toString('base64');
456
+ }
457
+
458
return newSrc;
459
}
460
@@ -307,10 +569,13 @@ async function parseExportNamesInto(
569
}
570
571
async function transformClientModule(
310
- body: any,
572
+ program: any,
573
url: string,
574
+ sourceMap: any,
575
loader: LoadFunction,
576
): Promise<string> {
577
+ const body = program.body;
578
+
579
const names: Array<string> = [];
580
581
await parseExportNamesInto(body, names, url, loader);
@@ -351,6 +616,9 @@ async function transformClientModule(
616
newSrc += JSON.stringify(url) + ',';
617
newSrc += JSON.stringify(name) + ');\n';
618
}
619
+
620
+ // TODO: Generate source maps for Client Reference functions so they can point to their
621
+ // original locations.
622
return newSrc;
623
}
624
@@ -391,12 +659,36 @@ async function transformModuleIfNeeded(
659
return source;
660
}
661
394
- let body;
662
+ let sourceMappingURL = null;
663
+ let sourceMappingStart = 0;
664
+ let sourceMappingEnd = 0;
665
+ let sourceMappingLines = 0;
666
+
667
+ let program;
668
try {
396
- body = acorn.parse(source, {
669
+ program = acorn.parse(source, {
670
ecmaVersion: '2024',
671
sourceType: 'module',
399
- }).body;
672
+ locations: true,
673
+ onComment(
674
+ block: boolean,
675
+ text: string,
676
+ start: number,
677
+ end: number,
678
+ startLoc: {line: number, column: number},
679
+ endLoc: {line: number, column: number},
680
+ ) {
681
+ if (
682
+ text.startsWith('# sourceMappingURL=') ||
683
+ text.startsWith('@ sourceMappingURL=')
684
+ ) {
685
+ sourceMappingURL = text.slice(19);
686
+ sourceMappingStart = start;
687
+ sourceMappingEnd = end;
688
+ sourceMappingLines = endLoc.line - startLoc.line;
689
+ }
690
+ },
691
+ });
692
} catch (x) {
693
// eslint-disable-next-line react-internal/no-production-logging
694
console.error('Error parsing %s %s', url, x.message);
@@ -405,6 +697,8 @@ async function transformModuleIfNeeded(
697
698
let useClient = false;
699
let useServer = false;
700
+
701
+ const body = program.body;
702
for (let i = 0; i < body.length; i++) {
703
const node = body[i];
704
if (node.type !== 'ExpressionStatement' || !node.directive) {
@@ -428,11 +722,38 @@ async function transformModuleIfNeeded(
722
);
723
}
724
725
+ let sourceMap = null;
726
+ if (sourceMappingURL) {
727
+ const sourceMapResult = await loader(
728
+ sourceMappingURL,
729
+ // $FlowFixMe
730
+ {
731
+ format: 'json',
732
+ conditions: [],
733
+ importAssertions: {type: 'json'},
734
+ importAttributes: {type: 'json'},
735
+ },
736
+ loader,
737
+ );
738
+ const sourceMapString =
739
+ typeof sourceMapResult.source === 'string'
740
+ ? sourceMapResult.source
741
+ : // $FlowFixMe
742
+ sourceMapResult.source.toString('utf8');
743
+ sourceMap = JSON.parse(sourceMapString);
744
+
745
+ // Strip the source mapping comment. We'll re-add it below if needed.
746
+ source =
747
+ source.slice(0, sourceMappingStart) +
748
+ '\n'.repeat(sourceMappingLines) +
749
+ source.slice(sourceMappingEnd);
750
+ }
751
+
752
if (useClient) {
432
- return transformClientModule(body, url, loader);
753
+ return transformClientModule(program, url, sourceMap, loader);
754
}
755
435
- return transformServerModule(source, body, url, loader);
756
+ return transformServerModule(source, program, url, sourceMap, loader);
757
}
758
759
export async function transformSource(