main
js 804 lines 23.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
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,
18 };
19
20 type ResolveFunction = (
21 string,
22 ResolveContext,
23 ResolveFunction,
24 ) => {url: string} | Promise<{url: string}>;
25
26 type GetSourceContext = {
27 format: string,
28 };
29
30 type GetSourceFunction = (
31 string,
32 GetSourceContext,
33 GetSourceFunction,
34 ) => Promise<{source: Source}>;
35
36 type TransformSourceContext = {
37 format: string,
38 url: string,
39 };
40
41 type TransformSourceFunction = (
42 Source,
43 TransformSourceContext,
44 TransformSourceFunction,
45 ) => Promise<{source: Source}>;
46
47 type LoadContext = {
48 conditions: Array<string>,
49 format: string | null | void,
50 importAssertions: Object,
51 };
52
53 type LoadFunction = (
54 string,
55 LoadContext,
56 LoadFunction,
57 ) => Promise<{format: string, shortCircuit?: boolean, source: Source}>;
58
59 type Source = string | ArrayBuffer | Uint8Array;
60
61 let warnedAboutConditionsFlag = false;
62
63 let stashedGetSource: null | GetSourceFunction = null;
64 let stashedResolve: null | ResolveFunction = null;
65
66 export async function resolve(
67 specifier: string,
68 context: ResolveContext,
69 defaultResolve: ResolveFunction,
70 ): Promise<{url: string}> {
71 // We stash this in case we end up needing to resolve export * statements later.
72 stashedResolve = defaultResolve;
73
74 if (!context.conditions.includes('react-server')) {
75 context = {
76 ...context,
77 conditions: [...context.conditions, 'react-server'],
78 };
79 if (!warnedAboutConditionsFlag) {
80 warnedAboutConditionsFlag = true;
81 // eslint-disable-next-line react-internal/no-production-logging
82 console.warn(
83 'You did not run Node.js with the `--conditions react-server` flag. ' +
84 'Any "react-server" override will only work with ESM imports.',
85 );
86 }
87 }
88 return await defaultResolve(specifier, context, defaultResolve);
89 }
90
91 export async function getSource(
92 url: string,
93 context: GetSourceContext,
94 defaultGetSource: GetSourceFunction,
95 ): Promise<{source: Source}> {
96 // We stash this in case we end up needing to resolve export * statements later.
97 stashedGetSource = defaultGetSource;
98 return defaultGetSource(url, context, defaultGetSource);
99 }
100
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':
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++)
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];
165 if (element)
166 addLocalExportedNames(exportedEntries, localNames, element);
167 }
168 return;
169 case 'Property':
170 addLocalExportedNames(exportedEntries, localNames, node.value);
171 return;
172 case 'AssignmentPattern':
173 addLocalExportedNames(exportedEntries, localNames, node.left);
174 return;
175 case 'RestElement':
176 addLocalExportedNames(exportedEntries, localNames, node.argument);
177 return;
178 case 'ParenthesizedExpression':
179 addLocalExportedNames(exportedEntries, localNames, node.expression);
180 return;
181 }
182 }
183
184 function transformServerModule(
185 source: string,
186 program: any,
187 url: string,
188 sourceMap: any,
189 loader: LoadFunction,
190 ): string {
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];
200 switch (node.type) {
201 case 'ExportAllDeclaration':
202 // If export * is used, the other file needs to explicitly opt into "use server" too.
203 break;
204 case 'ExportDefaultDeclaration':
205 if (node.declaration.type === 'Identifier') {
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) {
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 }
227 }
228 continue;
229 case 'ExportNamedDeclaration':
230 if (node.declaration) {
231 if (node.declaration.type === 'VariableDeclaration') {
232 const declarations = node.declaration.declarations;
233 for (let j = 0; j < declarations.length; j++) {
234 addLocalExportedNames(
235 exportedEntries,
236 localNames,
237 declarations[j].id,
238 );
239 }
240 } else {
241 const name = node.declaration.id.name;
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];
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 }
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 }
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
461 function addExportNames(names: Array<string>, node: any) {
462 switch (node.type) {
463 case 'Identifier':
464 names.push(node.name);
465 return;
466 case 'ObjectPattern':
467 for (let i = 0; i < node.properties.length; i++)
468 addExportNames(names, node.properties[i]);
469 return;
470 case 'ArrayPattern':
471 for (let i = 0; i < node.elements.length; i++) {
472 const element = node.elements[i];
473 if (element) addExportNames(names, element);
474 }
475 return;
476 case 'Property':
477 addExportNames(names, node.value);
478 return;
479 case 'AssignmentPattern':
480 addExportNames(names, node.left);
481 return;
482 case 'RestElement':
483 addExportNames(names, node.argument);
484 return;
485 case 'ParenthesizedExpression':
486 addExportNames(names, node.expression);
487 return;
488 }
489 }
490
491 function resolveClientImport(
492 specifier: string,
493 parentURL: string,
494 ): {url: string} | Promise<{url: string}> {
495 // Resolve an import specifier as if it was loaded by the client. This doesn't use
496 // the overrides that this loader does but instead reverts to the default.
497 // This resolution algorithm will not necessarily have the same configuration
498 // as the actual client loader. It should mostly work and if it doesn't you can
499 // always convert to explicit exported names instead.
500 const conditions = ['node', 'import'];
501 if (stashedResolve === null) {
502 throw new Error(
503 'Expected resolve to have been called before transformSource',
504 );
505 }
506 return stashedResolve(specifier, {conditions, parentURL}, stashedResolve);
507 }
508
509 async function parseExportNamesInto(
510 body: any,
511 names: Array<string>,
512 parentURL: string,
513 loader: LoadFunction,
514 ): Promise<void> {
515 for (let i = 0; i < body.length; i++) {
516 const node = body[i];
517 switch (node.type) {
518 case 'ExportAllDeclaration':
519 if (node.exported) {
520 addExportNames(names, node.exported);
521 continue;
522 } else {
523 const {url} = await resolveClientImport(node.source.value, parentURL);
524 const {source} = await loader(
525 url,
526 {format: 'module', conditions: [], importAssertions: {}},
527 loader,
528 );
529 if (typeof source !== 'string') {
530 throw new Error('Expected the transformed source to be a string.');
531 }
532 let childBody;
533 try {
534 childBody = acorn.parse(source, {
535 ecmaVersion: '2024',
536 sourceType: 'module',
537 }).body;
538 } catch (x) {
539 // eslint-disable-next-line react-internal/no-production-logging
540 console.error('Error parsing %s %s', url, x.message);
541 continue;
542 }
543 await parseExportNamesInto(childBody, names, url, loader);
544 continue;
545 }
546 case 'ExportDefaultDeclaration':
547 names.push('default');
548 continue;
549 case 'ExportNamedDeclaration':
550 if (node.declaration) {
551 if (node.declaration.type === 'VariableDeclaration') {
552 const declarations = node.declaration.declarations;
553 for (let j = 0; j < declarations.length; j++) {
554 addExportNames(names, declarations[j].id);
555 }
556 } else {
557 addExportNames(names, node.declaration.id);
558 }
559 }
560 if (node.specifiers) {
561 const specifiers = node.specifiers;
562 for (let j = 0; j < specifiers.length; j++) {
563 addExportNames(names, specifiers[j].exported);
564 }
565 }
566 continue;
567 }
568 }
569 }
570
571 async function transformClientModule(
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);
582
583 if (names.length === 0) {
584 return '';
585 }
586
587 let newSrc =
588 'import {registerClientReference} from "react-server-dom-esm/server";\n';
589 for (let i = 0; i < names.length; i++) {
590 const name = names[i];
591 if (name === 'default') {
592 newSrc += 'export default ';
593 newSrc += 'registerClientReference(function() {';
594 newSrc +=
595 'throw new Error(' +
596 JSON.stringify(
597 `Attempted to call the default export of ${url} from the server ` +
598 `but it's on the client. It's not possible to invoke a client function from ` +
599 `the server, it can only be rendered as a Component or passed to props of a ` +
600 `Client Component.`,
601 ) +
602 ');';
603 } else {
604 newSrc += 'export const ' + name + ' = ';
605 newSrc += 'registerClientReference(function() {';
606 newSrc +=
607 'throw new Error(' +
608 JSON.stringify(
609 `Attempted to call ${name}() from the server but ${name} is on the client. ` +
610 `It's not possible to invoke a client function from the server, it can ` +
611 `only be rendered as a Component or passed to props of a Client Component.`,
612 ) +
613 ');';
614 }
615 newSrc += '},';
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
625 async function loadClientImport(
626 url: string,
627 defaultTransformSource: TransformSourceFunction,
628 ): Promise<{format: string, shortCircuit?: boolean, source: Source}> {
629 if (stashedGetSource === null) {
630 throw new Error(
631 'Expected getSource to have been called before transformSource',
632 );
633 }
634 // TODO: Validate that this is another module by calling getFormat.
635 const {source} = await stashedGetSource(
636 url,
637 {format: 'module'},
638 stashedGetSource,
639 );
640 const result = await defaultTransformSource(
641 source,
642 {format: 'module', url},
643 defaultTransformSource,
644 );
645 return {format: 'module', source: result.source};
646 }
647
648 async function transformModuleIfNeeded(
649 source: string,
650 url: string,
651 loader: LoadFunction,
652 ): Promise<string> {
653 // Do a quick check for the exact string. If it doesn't exist, don't
654 // bother parsing.
655 if (
656 source.indexOf('use client') === -1 &&
657 source.indexOf('use server') === -1
658 ) {
659 return source;
660 }
661
662 let sourceMappingURL = null;
663 let sourceMappingStart = 0;
664 let sourceMappingEnd = 0;
665 let sourceMappingLines = 0;
666
667 let program;
668 try {
669 program = acorn.parse(source, {
670 ecmaVersion: '2024',
671 sourceType: 'module',
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);
695 return source;
696 }
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) {
705 break;
706 }
707 if (node.directive === 'use client') {
708 useClient = true;
709 }
710 if (node.directive === 'use server') {
711 useServer = true;
712 }
713 }
714
715 if (!useClient && !useServer) {
716 return source;
717 }
718
719 if (useClient && useServer) {
720 throw new Error(
721 'Cannot have both "use client" and "use server" directives in the same file.',
722 );
723 }
724
725 let sourceMap = null;
726 if (sourceMappingURL) {
727 const sourceMapResult = await loader(
728 sourceMappingURL,
729 // $FlowFixMe[incompatible-type]
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[extra-arg]
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) {
753 return transformClientModule(program, url, sourceMap, loader);
754 }
755
756 return transformServerModule(source, program, url, sourceMap, loader);
757 }
758
759 export async function transformSource(
760 source: Source,
761 context: TransformSourceContext,
762 defaultTransformSource: TransformSourceFunction,
763 ): Promise<{source: Source}> {
764 const transformed = await defaultTransformSource(
765 source,
766 context,
767 defaultTransformSource,
768 );
769 if (context.format === 'module') {
770 const transformedSource = transformed.source;
771 if (typeof transformedSource !== 'string') {
772 throw new Error('Expected source to have been transformed to a string.');
773 }
774 const newSrc = await transformModuleIfNeeded(
775 transformedSource,
776 context.url,
777 (url: string, ctx: LoadContext, defaultLoad: LoadFunction) => {
778 return loadClientImport(url, defaultTransformSource);
779 },
780 );
781 return {source: newSrc};
782 }
783 return transformed;
784 }
785
786 export async function load(
787 url: string,
788 context: LoadContext,
789 defaultLoad: LoadFunction,
790 ): Promise<{format: string, shortCircuit?: boolean, source: Source}> {
791 const result = await defaultLoad(url, context, defaultLoad);
792 if (result.format === 'module') {
793 if (typeof result.source !== 'string') {
794 throw new Error('Expected source to have been loaded into a string.');
795 }
796 const newSrc = await transformModuleIfNeeded(
797 result.source,
798 url,
799 defaultLoad,
800 );
801 return {format: 'module', source: newSrc};
802 }
803 return result;
804 }