main
js 526 lines 17.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 type {ImportManifestEntry} from './shared/ReactFlightImportMetadata';
11
12 import {join} from 'path';
13 import {pathToFileURL} from 'url';
14 import asyncLib from 'neo-async';
15 import * as acorn from 'acorn-loose';
16
17 import ModuleDependency from 'webpack/lib/dependencies/ModuleDependency';
18 import NullDependency from 'webpack/lib/dependencies/NullDependency';
19 import Template from 'webpack/lib/Template';
20 import {
21 sources,
22 WebpackError,
23 Compilation,
24 AsyncDependenciesBlock,
25 } from 'webpack';
26
27 import isArray from 'shared/isArray';
28
29 class ClientReferenceDependency extends ModuleDependency {
30 constructor(request: mixed) {
31 super(request);
32 }
33
34 get type(): string {
35 return 'client-reference';
36 }
37 }
38
39 // This is the module that will be used to anchor all client references to.
40 // I.e. it will have all the client files as async deps from this point on.
41 // We use the Flight client implementation because you can't get to these
42 // without the client runtime so it's the first time in the loading sequence
43 // you might want them.
44 const clientImportName = 'react-server-dom-webpack/client';
45 const clientFileName = require.resolve('../client.browser.js');
46
47 type ClientReferenceSearchPath = {
48 directory: string,
49 recursive?: boolean,
50 include: RegExp,
51 exclude?: RegExp,
52 };
53
54 type ClientReferencePath = string | ClientReferenceSearchPath;
55
56 type Options = {
57 isServer: boolean,
58 clientReferences?: ClientReferencePath | $ReadOnlyArray<ClientReferencePath>,
59 chunkName?: string,
60 clientManifestFilename?: string,
61 serverConsumerManifestFilename?: string,
62 };
63
64 const PLUGIN_NAME = 'React Server Plugin';
65
66 export default class ReactFlightWebpackPlugin {
67 clientReferences: $ReadOnlyArray<ClientReferencePath>;
68 chunkName: string;
69 clientManifestFilename: string;
70 serverConsumerManifestFilename: string;
71
72 constructor(options: Options) {
73 if (!options || typeof options.isServer !== 'boolean') {
74 throw new Error(
75 PLUGIN_NAME + ': You must specify the isServer option as a boolean.',
76 );
77 }
78 if (options.isServer) {
79 throw new Error('TODO: Implement the server compiler.');
80 }
81 if (!options.clientReferences) {
82 this.clientReferences = [
83 {
84 directory: '.',
85 recursive: true,
86 include: /\.(js|ts|jsx|tsx)$/,
87 },
88 ];
89 } else if (
90 typeof options.clientReferences === 'string' ||
91 !isArray(options.clientReferences)
92 ) {
93 this.clientReferences = [options.clientReferences as $FlowFixMe];
94 } else {
95 // $FlowFixMe[incompatible-type] found when upgrading Flow
96 this.clientReferences = options.clientReferences;
97 }
98 if (typeof options.chunkName === 'string') {
99 this.chunkName = options.chunkName;
100 if (!/\[(index|request)\]/.test(this.chunkName)) {
101 this.chunkName += '[index]';
102 }
103 } else {
104 this.chunkName = 'client[index]';
105 }
106 this.clientManifestFilename =
107 options.clientManifestFilename || 'react-client-manifest.json';
108 this.serverConsumerManifestFilename =
109 options.serverConsumerManifestFilename || 'react-ssr-manifest.json';
110 }
111
112 apply(compiler: any) {
113 const _this = this;
114 let resolvedClientReferences;
115 let clientFileNameFound = false;
116
117 // Find all client files on the file system
118 compiler.hooks.beforeCompile.tapAsync(
119 PLUGIN_NAME,
120 ({contextModuleFactory}, callback) => {
121 const contextResolver = compiler.resolverFactory.get('context', {});
122 const normalResolver = compiler.resolverFactory.get('normal');
123
124 _this.resolveAllClientFiles(
125 compiler.context,
126 contextResolver,
127 normalResolver,
128 compiler.inputFileSystem,
129 contextModuleFactory,
130 function (err, resolvedClientRefs) {
131 if (err) {
132 callback(err);
133 return;
134 }
135
136 resolvedClientReferences = resolvedClientRefs;
137 callback();
138 },
139 );
140 },
141 );
142
143 compiler.hooks.thisCompilation.tap(
144 PLUGIN_NAME,
145 (compilation, {normalModuleFactory}) => {
146 compilation.dependencyFactories.set(
147 ClientReferenceDependency,
148 normalModuleFactory,
149 );
150 compilation.dependencyTemplates.set(
151 ClientReferenceDependency,
152 new NullDependency.Template(),
153 );
154
155 // $FlowFixMe[missing-local-annot]
156 const handler = parser => {
157 // We need to add all client references as dependency of something in the graph so
158 // Webpack knows which entries need to know about the relevant chunks and include the
159 // map in their runtime. The things that actually resolves the dependency is the Flight
160 // client runtime. So we add them as a dependency of the Flight client runtime.
161 // Anything that imports the runtime will be made aware of these chunks.
162 parser.hooks.program.tap(PLUGIN_NAME, () => {
163 const module = parser.state.module;
164
165 if (module.resource !== clientFileName) {
166 return;
167 }
168
169 clientFileNameFound = true;
170
171 if (resolvedClientReferences) {
172 // $FlowFixMe[incompatible-use] found when upgrading Flow
173 for (let i = 0; i < resolvedClientReferences.length; i++) {
174 // $FlowFixMe[incompatible-use] found when upgrading Flow
175 const dep = resolvedClientReferences[i];
176
177 const chunkName = _this.chunkName
178 .replace(/\[index\]/g, '' + i)
179 .replace(/\[request\]/g, Template.toPath(dep.userRequest));
180
181 const block = new AsyncDependenciesBlock(
182 {
183 name: chunkName,
184 },
185 null,
186 dep.request,
187 );
188
189 block.addDependency(dep);
190 module.addBlock(block);
191 }
192 }
193 });
194 };
195
196 normalModuleFactory.hooks.parser
197 .for('javascript/auto')
198 .tap('HarmonyModulesPlugin', handler);
199
200 normalModuleFactory.hooks.parser
201 .for('javascript/esm')
202 .tap('HarmonyModulesPlugin', handler);
203
204 normalModuleFactory.hooks.parser
205 .for('javascript/dynamic')
206 .tap('HarmonyModulesPlugin', handler);
207 },
208 );
209
210 compiler.hooks.make.tap(PLUGIN_NAME, compilation => {
211 compilation.hooks.processAssets.tap(
212 {
213 name: PLUGIN_NAME,
214 stage: Compilation.PROCESS_ASSETS_STAGE_REPORT,
215 },
216 function () {
217 if (clientFileNameFound === false) {
218 compilation.warnings.push(
219 new WebpackError(
220 `Client runtime at ${clientImportName} was not found. React Server Components module map file ${_this.clientManifestFilename} was not created.`,
221 ),
222 );
223 return;
224 }
225
226 const configuredCrossOriginLoading =
227 compilation.outputOptions.crossOriginLoading;
228 const crossOriginMode =
229 typeof configuredCrossOriginLoading === 'string'
230 ? configuredCrossOriginLoading === 'use-credentials'
231 ? configuredCrossOriginLoading
232 : 'anonymous'
233 : null;
234
235 const resolvedClientFiles = new Set(
236 (resolvedClientReferences || []).map(ref => ref.request),
237 );
238
239 const clientManifest: {
240 [string]: ImportManifestEntry,
241 } = {};
242 type ServerConsumerModuleMap = {
243 [string]: {
244 [string]: {specifier: string, name: string},
245 },
246 };
247 const moduleMap: ServerConsumerModuleMap = {};
248 const ssrBundleConfig: {
249 moduleLoading: {
250 prefix: string,
251 crossOrigin: string | null,
252 },
253 moduleMap: ServerConsumerModuleMap,
254 } = {
255 moduleLoading: {
256 prefix: compilation.outputOptions.publicPath || '',
257 crossOrigin: crossOriginMode,
258 },
259 moduleMap,
260 };
261
262 // We figure out which files are always loaded by any initial chunk (entrypoint).
263 // We use this to filter out chunks that Flight will never need to load
264 const emptySet: Set<string> = new Set();
265 const runtimeChunkFiles: Set<string> = emptySet;
266 compilation.entrypoints.forEach(entrypoint => {
267 const runtimeChunk = entrypoint.getRuntimeChunk();
268 if (runtimeChunk) {
269 runtimeChunk.files.forEach(runtimeFile => {
270 runtimeChunkFiles.add(runtimeFile);
271 });
272 }
273 });
274
275 compilation.chunkGroups.forEach(function (chunkGroup) {
276 const chunks: Array<string> = [];
277 chunkGroup.chunks.forEach(function (c) {
278 // eslint-disable-next-line no-for-of-loops/no-for-of-loops
279 for (const file of c.files) {
280 if (!(file.endsWith('.js') || file.endsWith('.mjs'))) {
281 return;
282 }
283 if (
284 file.endsWith('.hot-update.js') ||
285 file.endsWith('.hot-update.mjs')
286 )
287 return;
288 chunks.push(c.id, file);
289 break;
290 }
291 });
292
293 // $FlowFixMe[missing-local-annot]
294 function recordModule(id: $FlowFixMe, module) {
295 // TODO: Hook into deps instead of the target module.
296 // That way we know by the type of dep whether to include.
297 // It also resolves conflicts when the same module is in multiple chunks.
298 if (!resolvedClientFiles.has(module.resource)) {
299 return;
300 }
301
302 const href = pathToFileURL(module.resource).href;
303
304 if (href !== undefined) {
305 const ssrExports: {
306 [string]: {specifier: string, name: string},
307 } = {};
308
309 clientManifest[href] = {
310 id,
311 chunks,
312 name: '*',
313 };
314 ssrExports['*'] = {
315 specifier: href,
316 name: '*',
317 };
318
319 // TODO: If this module ends up split into multiple modules, then
320 // we should encode each the chunks needed for the specific export.
321 // When the module isn't split, it doesn't matter and we can just
322 // encode the id of the whole module. This code doesn't currently
323 // deal with module splitting so is likely broken from ESM anyway.
324 /*
325 clientManifest[href + '#'] = {
326 id,
327 chunks,
328 name: '',
329 };
330 ssrExports[''] = {
331 specifier: href,
332 name: '',
333 };
334
335 const moduleProvidedExports = compilation.moduleGraph
336 .getExportsInfo(module)
337 .getProvidedExports();
338
339 if (Array.isArray(moduleProvidedExports)) {
340 moduleProvidedExports.forEach(function (name) {
341 clientManifest[href + '#' + name] = {
342 id,
343 chunks,
344 name: name,
345 };
346 ssrExports[name] = {
347 specifier: href,
348 name: name,
349 };
350 });
351 }
352 */
353
354 moduleMap[id] = ssrExports;
355 }
356 }
357
358 chunkGroup.chunks.forEach(function (chunk) {
359 const chunkModules =
360 compilation.chunkGraph.getChunkModulesIterable(chunk);
361
362 Array.from(chunkModules).forEach(function (module) {
363 const moduleId = compilation.chunkGraph.getModuleId(module);
364
365 recordModule(moduleId, module);
366 // If this is a concatenation, register each child to the parent ID.
367 if (module.modules) {
368 module.modules.forEach(concatenatedMod => {
369 recordModule(moduleId, concatenatedMod);
370 });
371 }
372 });
373 });
374 });
375
376 const clientOutput = JSON.stringify(clientManifest, null, 2);
377 compilation.emitAsset(
378 _this.clientManifestFilename,
379 new sources.RawSource(clientOutput, false),
380 );
381 const ssrOutput = JSON.stringify(ssrBundleConfig, null, 2);
382 compilation.emitAsset(
383 _this.serverConsumerManifestFilename,
384 new sources.RawSource(ssrOutput, false),
385 );
386 },
387 );
388 });
389 }
390
391 // This attempts to replicate the dynamic file path resolution used for other wildcard
392 // resolution in Webpack is using.
393 resolveAllClientFiles(
394 context: string,
395 contextResolver: any,
396 normalResolver: any,
397 fs: any,
398 contextModuleFactory: any,
399 callback: (
400 err: null | Error,
401 result?: $ReadOnlyArray<ClientReferenceDependency>,
402 ) => void,
403 ) {
404 function hasUseClientDirective(source: string): boolean {
405 if (source.indexOf('use client') === -1) {
406 return false;
407 }
408 let body;
409 try {
410 body = acorn.parse(source, {
411 ecmaVersion: '2024',
412 sourceType: 'module',
413 }).body;
414 } catch (x) {
415 return false;
416 }
417 for (let i = 0; i < body.length; i++) {
418 const node = body[i];
419 if (node.type !== 'ExpressionStatement' || !node.directive) {
420 break;
421 }
422 if (node.directive === 'use client') {
423 return true;
424 }
425 }
426 return false;
427 }
428
429 asyncLib.map(
430 this.clientReferences,
431 (
432 clientReferencePath: string | ClientReferenceSearchPath,
433 cb: (
434 err: null | Error,
435 result?: $ReadOnlyArray<ClientReferenceDependency>,
436 ) => void,
437 ): void => {
438 if (typeof clientReferencePath === 'string') {
439 cb(null, [new ClientReferenceDependency(clientReferencePath)]);
440 return;
441 }
442 const clientReferenceSearch: ClientReferenceSearchPath =
443 clientReferencePath;
444 contextResolver.resolve(
445 {},
446 context,
447 clientReferencePath.directory,
448 {},
449 (err, resolvedDirectory) => {
450 if (err) return cb(err);
451 const options = {
452 resource: resolvedDirectory,
453 resourceQuery: '',
454 recursive:
455 clientReferenceSearch.recursive === undefined
456 ? true
457 : clientReferenceSearch.recursive,
458 regExp: clientReferenceSearch.include,
459 include: undefined,
460 exclude: clientReferenceSearch.exclude,
461 };
462 contextModuleFactory.resolveDependencies(
463 fs,
464 options,
465 (err2: null | Error, deps: Array<any /*ModuleDependency*/>) => {
466 if (err2) return cb(err2);
467
468 const clientRefDeps = deps.map(dep => {
469 // use userRequest instead of request. request always end with undefined which is wrong
470 const request = join(resolvedDirectory, dep.userRequest);
471 const clientRefDep = new ClientReferenceDependency(request);
472 clientRefDep.userRequest = dep.userRequest;
473 return clientRefDep;
474 });
475
476 asyncLib.filter(
477 clientRefDeps,
478 (
479 clientRefDep: ClientReferenceDependency,
480 filterCb: (err: null | Error, truthValue: boolean) => void,
481 ) => {
482 normalResolver.resolve(
483 {},
484 context,
485 clientRefDep.request,
486 {},
487 (err3: null | Error, resolvedPath: mixed) => {
488 if (err3 || typeof resolvedPath !== 'string') {
489 return filterCb(null, false);
490 }
491 fs.readFile(
492 resolvedPath,
493 'utf-8',
494 (err4: null | Error, content: string) => {
495 if (err4 || typeof content !== 'string') {
496 return filterCb(null, false);
497 }
498 const useClient = hasUseClientDirective(content);
499 filterCb(null, useClient);
500 },
501 );
502 },
503 );
504 },
505 cb,
506 );
507 },
508 );
509 },
510 );
511 },
512 (
513 err: null | Error,
514 result: $ReadOnlyArray<$ReadOnlyArray<ClientReferenceDependency>>,
515 ): void => {
516 if (err) return callback(err);
517 const flat: Array<any> = [];
518 for (let i = 0; i < result.length; i++) {
519 // $FlowFixMe[method-unbinding]
520 flat.push.apply(flat, result[i]);
521 }
522 callback(null, flat);
523 },
524 );
525 }
526 }