@samitouri / QOS-React / commits / 97e2ce6a00

[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(