| 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 | } |