
深入 TanStack Start 的 Rsbuild/Rspack 编译器架构服务端函数发现、持久化缓存与解析器虚拟模块【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/routerTanStack Start 的 Rsbuild/Rspack 集成需要同时完成三件事编译 Start 特有的源码模式服务端函数、同构函数、中间件、编译器虚拟模块、在 Rspack 从持久化缓存恢复模块并跳过转换时仍保留服务端函数元数据、以及在当前编译的服务端函数注册表完整后生成#tanstack-start-server-fn-resolver虚拟模块。本文以 COMPILER_ARCHITECTURE.md 为核心骨架结合 start-compiler-host.ts、start-compiler-metadata-loader.ts、handleCreateServerFn.ts 等源码完整还原这套「编译期发现 → 模块级持久化 → 缓存回放 → 解析器重建」的架构设计。读完本文你将掌握 Start 如何在 Rspack 的 loader 管线中安全写入module.buildInfo、如何按 Rsbuild 环境划分元数据快照、如何编排多编译器构建顺序以及为什么 RSC 构建必须放弃MultiCompiler.setDependencies()。背景为什么需要一套专门的编译器集成TanStack Start 的编译器需要把开发者写的createServerFn()等服务端函数源码改写成可在浏览器、SSR、服务端执行三种上下文分别运行的 RPC 形态。这项工作发生在 Rsbuild 的 loader 管线里而 Rspack 拥有独立的持久化缓存机制——这意味着编译与缓存之间存在天然冲突服务端函数发现是编译的副作用。只有当源码真正经过 Start 转换器处理时handleCreateServerFn()才会发现新的函数并登记到注册表如果 Rspack 从持久化缓存恢复了一个已转换的模块转换副作用不会再次运行函数发现过程就被跳过了因此Start 选择把发现到的元数据直接存放在 Rspack 模块自身上module.buildInfo并在缓存恢复时从buildInfo重放。这个核心思想贯穿全文元数据跟着模块走而不是跟着转换器走。核心文件地图整个集成由以下文件组成相对仓库根目录文件职责plugin.ts组装 Rsbuild 插件阶段、虚拟模块、RSC 钩子、编译器排序与解析器重建钩子start-compiler-host.ts注册 Start 编译器转换器、捕获服务端函数元数据、把元数据写入 RspackbuildInfo、回放缓存元数据start-compiler-metadata.ts定义元数据 loader 使用的字符串键与类型start-compiler-metadata-loader.ts真正的 Rspack loader把服务端函数元数据写入当前模块的buildInfovirtual-modules.ts托管解析器虚拟模块按 Rsbuild 环境写入更新后的虚拟模块内容handleCreateServerFn.ts在编译调用方模块时发现服务端函数server-fn-resolver-module.ts根据当前服务端函数注册表生成解析器模块多环境模型每个环境发现不同的信息Start 为每个会影响服务端函数元数据的 Rsbuild 环境注册编译器工作。在 planning.ts 中定义了两个标准环境名export const RSBUILD_ENVIRONMENT_NAMES { client: client, server: ssr, } as constplugin.ts 据此构造参与编译的环境列表const startCompilerEnvironments [ { name: RSBUILD_ENVIRONMENT_NAMES.client, type: client as const }, { name: RSBUILD_ENVIRONMENT_NAMES.server, type: server as const }, ...(serverFnProviderEnv ! RSBUILD_ENVIRONMENT_NAMES.server !rscEnabled ? [{ name: serverFnProviderEnv, type: server as const }] : []), ]各环境的分工如下client 环境从浏览器代码中发现被引用的函数并将其标记为 client-accessibleisClientReferenced truessr 环境发现从服务端渲染路由图可达的函数provider 环境仅当服务端函数 provider 没有编译进ssr且未启用 RSC 时才会额外加入一个独立的 provider 环境。所有环境共享同一个注册表serverFnsById。它被传给 virtual-modules.ts后者据此生成解析器模块内容。冷编译路径转换器如何发现函数registerStartCompilerTransforms()为每个 Start 编译器环境注册一个api.transform({ order: pre })转换器。当模块正常编译时执行以下流程对应 start-compiler-host.ts 的实现Rsbuild 在 pre-loader 阶段运行 Start 转换器转换器先检查编译器虚拟模块loadCompilerVirtualModule命中则直接使用插件提供的虚拟模块代码运行廉价的字符串级代码过滤器matchesCodeFilters跳过不可能包含 Start 编译器模式的文件按环境懒创建唯一的StartCompiler实例缓存在compilersMap 中通过detectKindsInCode检测当前代码中存在的编译器特性种类通过runCompilerTask()串行化对每个环境StartCompiler的访问调用compiler.compile({ id, code, detectedKinds })编译期间handleCreateServerFn()通过onServerFnsById回调报告发现的服务端函数转换器把该模块发现的服务端函数存入该环境的 pending 元数据 MapserverFnMetadataByEnvironment。值得注意的两处实现细节模块 ID 使用ctx.resource。转换器记录元数据时用ctx.resource作为键元数据 loader 之后读取this.resource。两者是同一个 Rspack resource 字符串保证了跨 resource query 的交接稳定start-compiler-host.ts中const id ctx.resource。编译器任务按环境串行化。runCompilerTask()通过 promise 队列compilerQueues保证同一环境的编译任务依次执行原因有两点StartCompiler拥有可变模块缓存activeServerFnMetadata必须只描述当前正在该环境编译的模块。服务端函数发现在调用方模块中登记服务端函数在调用方模块caller module中被发现而非 provider 模块。在 handleCreateServerFn.ts 中每个被发现的函数都会记录以下字段类型定义见 types.tsexport interface ServerFn { /** 用于导出该函数的唯一名称 */ functionName: string /** 用于 RPC 调用的唯一 ID */ functionId: string /** 带查询参数的提取实现所在文件名 */ extractedFilename: string /** 原始源文件名 */ filename: string /** 该函数是否被客户端构建发现用于限制仅客户端引用的函数可被 HTTP 访问 */ isClientReferenced?: boolean }functionName生成的处理器导出名格式为${变量名}_createServerFn_handler同一文件内若重名则追加数字后缀incrementFunctionNameVersionfunctionId稳定的服务端函数 ID由generateFunctionId({ filename, functionName, extractedFilename })生成filename调用方模块 IDextractedFilename带服务端函数拆分查询参数?tss-serverfn-split的 provider 模块 IDisClientReferenced是否允许来自客户端源的调用。isClientReferenced的判定逻辑体现了三种来源的合并handleCreateServerFn.tsconst isClientReferenced envConfig.isClientEnvironment || // 1. 当前就是客户端环境 !!knownFn || // 2. 已被其他环境发现 envConfig.runtimeCodeType ssr // 3. SSR 调用方可达即视为客户端可调用第 3 条的设计意图很明确任何从 SSR 模块图可达的服务端函数都可以通过客户端导航的 HTTP 请求被调用因此必须标记为客户端可引用。onServerFnsById会把发现结果合并进共享的serverFnsById注册表当编译任务进行中时同一批结果还会合并进该模块的activeServerFnMetadata对象。编译结束后该对象即成为模块的元数据负载。在 host.ts 中可以看到合并函数的语义——isClientReferenced采用**或OR**合并export function mergeServerFnsById( current: Recordstring, ServerFn, next: Recordstring, ServerFn, ): void { for (const [id, fn] of Object.entries(next)) { const existing current[id] if (existing) { current[id] { ...fn, isClientReferenced: existing.isClientReferenced || fn.isClientReferenced, } continue } current[id] fn } }模块加载与解析复用原生 Rsbuild/Rspack 接口编译器在编译源码时有时需要读取或解析被导入的模块。start-compiler-host.ts使用两个原生 Rsbuild/Rspack 能力ctx.resolve()来自当前 Rsbuild 转换上下文用于解析模块 IDstart-compiler-host.ts的resolveId中通过回调形式activeCtx.resolve(context, source, cb)调用并对结果执行cleanIdcompiler.inputFileSystem通过 Rspack 的输入文件系统读取源码readFileFromInputFileSystem支持 Buffer 与字符串两种返回值。关键设计是用AsyncLocalStorage把loadModule和resolveId绑定回当前激活的转换上下文transformContextStorage.run(ctx, ...)。这让编译器可以在不触碰 Rspack 私有 resolver 内部结构的前提下把依赖添加到当前 Rspack 模块上——因为loadModule中调用了activeCtx.addDependency(cleanedId)。在 dev 模式下模块说明符的编码也有专门处理start-compiler-host.ts 中的rsbuildDevServerFnModuleSpecifierEncoder对绝对路径的服务端函数使用pathToFileURL(extractedFilename).href生成file://URL。这与 Vite 的/id/前缀约定不同file://URL 可以直接被 Node 的 ESM VM runner 导入无需任何打包器路径约定。为什么需要一个真正的 loaderRsbuild 的api.transform()本质上是一个 loader但它的回调只暴露转换上下文不暴露当前的NormalModule或module.buildInfo。而 Rspack 会持久化自定义的module.buildInfo字段——这正是写入元数据的理想位置。于是 Start 在转换器之后安装了一个小而真实的 loaderstart-compiler-host.ts为 Start 可转换模块添加一条enforce: post规则start-compiler-host.ts规则指向编译产物start-compiler-metadata-loader.jsresolveMetadataLoader()解析出其绝对路径loader 通过 loader options 接收该环境的 pending 元数据 MapmetadataById: getServerFnMetadata(utils.environment.name)通过公开的NormalModule.getCompilationHooks(compilation).loader钩子向 loader 上下文注入 settersetServerFnBuildInfoLoaderContextloader 读取this.resource对应的元数据并调用 settersetter 写入module.buildInfo[tanstack.start.serverFns]。只使用字符串键、可 JSON 序列化的buildInfo字段。字段名与上下文键定义在 start-compiler-metadata.tsexport const SERVER_FN_BUILD_INFO_FIELD tanstack.start.serverFns export const SERVER_FN_BUILD_INFO_CONTEXT_KEY tanstack.start.setServerFnBuildInfo export type ServerFnBuildInfo { version: 1 serverFnsById: Recordstring, ServerFn }之所以刻意避开 Symbol 键和私有缓存内部结构是因为 Rspack 只会持久化字符串键、JSON 可序列化的字段Symbol 键或私有 cache 内部结构无法可靠跨缓存存活。真实 loader 的实现非常简洁start-compiler-metadata-loader.tsconst tanStackStartCompilerMetadataLoader: Rspack.LoaderDefinition ServerFnMetadataLoaderOptions, ServerFnBuildInfoLoaderContext function (source, map): void { const { metadataById } this.getOptions() const id this.resource const metadata metadataById.get(id) const setBuildInfo this[SERVER_FN_BUILD_INFO_CONTEXT_KEY] setBuildInfo?.(metadata ?? null) this.callback(null, source, map) }缓存启用与禁用同一路径两种结局元数据 loader 的安装与performance.buildCache无关两种模式下都会安装持久化缓存启用Rspack 可能直接恢复模块而不重新运行 Start 转换器和元数据 loader此时buildInfo负载从缓存模块重放持久化缓存禁用同一 loader 路径仍会在正常编译时运行把元数据写入内存中的buildInfo。保持单一路径的好处是冷启动、watch、缓存禁用、缓存启用四种构建模式行为一致不存在条件分支导致的语义分叉。代价仅为对已经匹配 Start 转换测试的模块多运行一个很小的 post loader。文档特别强调缓存相关的部分不是 loader 本身而是 Rspack 可以持久化并稍后恢复buildInfo字段。这一句话是理解整个设计的分水岭——Start 没有去控制缓存是否发生而是让元数据搭上 Rspack 缓存机制本身的便车。陈旧元数据清理如果某个模块之前包含服务端函数、编辑后不再产生元数据元数据 loader 会写入空负载{ version: 1, serverFnsById: {} }对应 start-compiler-host.ts 中的EMPTY_SERVER_FN_BUILD_INFO常量。此外setServerFnBuildInfoLoaderContext中的 setter 也体现了同样的防御逻辑当传入metadata为null且模块上已有旧buildInfo时重置为空负载。这可以防止文件编辑删除或重命名服务端函数后旧的服务端函数条目残留在缓存模块的buildInfo上。同时pending 的「转换器 → loader」元数据 Map 会在每次 Rspackcompile钩子触发时清空getServerFnMetadata(utils.environment.name).clear()。这防止上一次编译发现的元数据在转换器不再运行或不再发现函数时被写入后续模块。温缓存回放finishMake阶段 -20每个相关编译器都安装一个finishMake钩子阶段为-20。此刻 Rspack 已经为该环境构建或恢复了模块图module.buildInfo可用。回放流程start-compiler-host.ts遍历compilation.modules读取module.buildInfo[tanstack.start.serverFns]用带版本的 schemaserverFnBuildInfoSchema基于 zod要求version 1校验负载校验失败则跳过把有效的模块元数据合并进一个环境快照快照存入serverFnsByEnvironment从所有环境快照重建共享的serverFnsById注册表replaceServerFnsByIdFromEnvironmentSnapshots通知虚拟模块解析器内容可能已变化。这里有一个关键设计共享注册表是从快照重建而非追加式更新。replaceServerFnsByIdFromEnvironmentSnapshots先清空现有注册表再逐快照合并。正因为如此被删除或重命名的服务端函数才能在 watch 重建或温缓存恢复后从注册表中消失——如果采用追加式合并旧条目会永远存活。与之配套的是 watch 失效处理start-compiler-host.ts在watchRun钩子中对modifiedFiles和removedFiles中的每个文件调用startCompiler.invalidateModule(file)确保 Start 编译器内部的模块缓存同步失效。为什么快照要按环境划分同一个文件在不同 Rsbuild 环境下可能看到不同的信息client 与 SSR 调用方环境可以把函数标记为 client-accessibleserver 与 provider 环境则拥有服务端执行细节。合并后的注册表保留最广的已知访问信息isClientReferenced的 OR 合并正体现这一点。如果做单一全局回放要么丢掉另一个环境的元数据要么让陈旧元数据存活过久。因此模型是每个环境构建一个当前快照用所有可用快照的并集替换共享注册表从共享注册表重新生成解析器内容。这让全局注册表保持收敛convergent同时允许各 Rspack 环境独立完成构建。解析器重建顺序finishMake阶段 -10解析器虚拟模块必须在缓存元数据回放之后重建。两级finishMake钩子的顺序是阶段 -20把buildInfo回放进环境快照重建serverFnsById阶段 -10写入解析器虚拟模块内容并重建所有导入解析器的模块。对于非 RSC 构建每个需要解析器的 server 类环境都会安装阶段 -10 的重建钩子plugin.tscompiler.hooks.finishMake.tapPromise( { name: TanStackStartServerFnResolverRebuild, stage: -10, }, async (compilation: RspackCompilationExtended) { virtualModuleState.updateServerFnResolver() await rebuildModulesContaining( compilation, virtualModuleState.serverFnResolverPath, ) }, )rebuildModulesContaining是本地辅助函数plugin.ts它遍历compilation.modules对 identifier 包含指定片段的模块逐个调用compilation.rebuildModule(mod, cb)并聚合成一个Promise.all。对于 RSC 构建server 环境在阶段 -10 安装的是RSC 解析器重建钩子plugin.tscompiler.hooks.finishMake.tapPromise( { name: TanStackStartRscServerFnResolverRebuild, stage: -10, }, async (compilation: RspackCompilationExtended) { const resolverContent virtualModuleState.generateCurrentResolverContent(true) virtualModuleState.tryUpdateServerFnResolver(resolverContent) await rebuildModulesContaining( compilation, virtualModuleState.serverFnResolverPath, ) }, )注意generateCurrentResolverContent(true)中的true它生成provider 风格的解析器内容。原因在文档中说明——RSC server actions 在 server/RSC 环境内部运行那一层不需要 client-reference 检查。解析器模块的生成server-fn-resolver-module.ts 负责生成解析器内容核心是getServerFnByIdexport async function getServerFnById(id, access) { const serverFnInfo manifest[id] if (!serverFnInfo) { throw new Error(Server function info not found for id) } if (access.origin client !serverFnInfo.isClientReferenced) { throw new Error(Server function not accessible from client: id) } const fnModule serverFnInfo.module ?? (await serverFnInfo.importer()) if (!fnModule) { throw new Error(Server function module not resolved for id) } const action fnModule[serverFnInfo.functionName] if (!action) { throw new Error(Server function module export not resolved for serverFn ID: id) } return action }其中__CLIENT_REFERENCED_CHECK__是占位符由getClientReferencedCheck根据includeClientReferencedCheck参数决定是否注入——非 provider 环境注入检查provider 环境注入空串。模块访问有两种方式默认用importer: () import(extractedFilename)动态导入当useStaticImports为真RSC dev时改用静态import * as serverFnModuleN from ...并直接引用module:字段。解析器清单还有一个对可复现构建至关重要的细节条目按 ID 排序Object.entries(...).sort(...)保证生成的清单顺序确定。文档注释说明非确定性顺序会导致同一源文件编译出的 hash 变化破坏内容寻址缓存和可复现部署。编译器排序MultiCompiler.setDependencies与 RSC 例外非 RSC 的 Rsbuild 构建使用 Rspack 的MultiCompiler.setDependencies()让解析器所属环境等待元数据产生环境plugin.tsapi.onAfterCreateCompiler(({ compiler }) { if (compilers in compiler) { for (const environmentName of startCompilerServerEnvironmentNames) { const serverCompiler compiler.compilers.find( (c) c.name environmentName, ) if (serverCompiler) { const dependencies: Arraystring [ RSBUILD_ENVIRONMENT_NAMES.client, ] if ( environmentName RSBUILD_ENVIRONMENT_NAMES.server serverFnProviderEnv ! RSBUILD_ENVIRONMENT_NAMES.server ) { dependencies.push(serverFnProviderEnv) } compiler.setDependencies(serverCompiler, dependencies) } } } })意图明确的顺序是client先于所有 server 类环境运行若存在独立 provider 环境provider 在client之后运行若存在独立 provider 环境ssr在client和 provider 之后运行。这个顺序防止ssr的解析器模块在 provider 元数据可用之前就被最终化。RSC 构建则有意不添加这些依赖。文档与代码注释都指出了死锁风险Rspack 原生的 RSC coordinator 通过交错 server 与 client 编译阶段来编排顺序如果在它之上再叠加MultiCompiler依赖MultiCompiler会阻塞 server 编译器直到 clientdone而 coordinator 又阻塞 client 的make钩子直到 server 的 entries 阶段完成——两者互相等待构建死锁。这也是plugin.ts中 manifest 生成逻辑为 RSC 保留占位符资产替换兜底路径START_MANIFEST_PLACEHOLDER字符串替换的原因。RSC 构建的额外编排layer 规则与原生插件当 RSC 启用时plugin.ts 还会懒创建rspack.experiments.rsc.createPlugins()插件对server 环境挂ServerPlugin、client 环境挂ClientPlugin由 Coordinator 自动处理编译顺序server → client → server-actions添加issuerLayer 规则带?tss-serverfn-splitresource query 的 provider 模块进入 RSC layerreact-server-components见 planning.ts 的RSBUILD_RSC_LAYERS使用react-serverresolve condition从 RSC layer 导入的普通模块继承react-server条件传递传播但带?tsr-split的路由拆分虚拟模块除外通过enableSwcReactServerComponents在 server 构建的 RSC provider 子树、client 构建的全量范围启用 SWC 的 RSC 指令检测在 server 环境把应用根目录node_modules显式种入resolve.modulesseedResolveModules因为react-server-dom-rspack/server等包可能解析自应用根之外RSC 的 HMR 通过sockWrite(custom, { event: rsc:update })推送client 端的setupRscHmr监听import.meta.webpackHot.on(rsc:update)后调用router.invalidate()。虚拟模块在 RSC 下的形态也由 virtual-modules.ts 统一管理RSC layer 内virtual:tanstack-rsc-runtime从react-server-dom-rspack/server重新导出运行时函数其他 layer 提供抛错的 stubclient 环境提供rsc-browser-decode、server 环境提供rsc-ssr-decode。原生 API 清单与刻意回避的禁区实现使用的全部是 Rsbuild/Rspack 公开集成点而非缓存内部机制Rsbuildapi.transform()源码转换Rsbuild 转换上下文ctx.resolve()模块解析Rspackcompiler.inputFileSystem源码读取Rspack loader接入 loader 管线RspackNormalModule.getCompilationHooks(compilation).loader扩展 loader 上下文Rspackmodule.buildInfo模块级持久化元数据Rspackcompiler.hooks.finishMake模块图回放与解析器重建排序Rspackcompilation.rebuildModule()经rebuildModulesContaining辅助函数RspackMultiCompiler.setDependencies()非 RSC 编译器排序Rspackexperiments.VirtualModulesPlugin虚拟模块内容。同时实现刻意回避私有持久化缓存文件、直接修改缓存、在 loader 中直接访问_module、全局交接状态、以及用this.cacheable(false)禁用模块缓存。这些禁区要么绕过公开 API 导致脆弱要么破坏 Rspack 的缓存能力——而本架构恰恰要依赖缓存来承载元数据。虚拟模块的路径生成也有讲究virtual-modules.tsVirtualModulesPlugin相对compiler.context解析路径因此虚拟模块被放在${root}/node_modules/.virtual/${sanitized}.js下既保证唯一性又对 watcher 友好同时用NormalModuleReplacementPlugin把virtual:tanstack-*这类 scheme 式 ID 重写为虚拟文件路径Rspack 会在普通 alias 解析之前校验 request scheme。新服务端函数的一次完整冷编译旅程最后把全文串成一条线。当一个新的服务端函数在普通冷编译中出现时对应文档的 9 步流程Rsbuild 把源模块送入 Start pre 转换器StartCompiler.compile()重写调用方模块并发现服务端函数onServerFnsById把函数合并进serverFnsById与当前激活模块的元数据对象转换器以模块 resource 为键把{ version: 1, serverFnsById: discoveredServerFnsById }存入环境元数据 Mappost 元数据 loader 为同一 resource 运行loader 把该负载写入module.buildInfo[tanstack.start.serverFns]之后模块归 Rspack 所有——若启用持久化缓存Rspack 用其常规缓存机制持久化模块及其字符串键buildInfo在finishMake -20Start 读取当前模块buildInfo并重建环境快照在finishMake -10Start 重写并重建解析器虚拟模块使运行时查找能找到新函数。至此一个「编译期发现 → 模块级持久化 → 缓存回放 → 注册表重建 → 解析器再生成」的完整闭环成立。无论构建来自冷启动、watch 重建还是温缓存恢复服务端函数注册表都能收敛到正确状态这正是这套架构的核心价值让函数发现成为模块生命周期的一部分而非依赖某次恰好发生的转换。延伸阅读架构总览文档COMPILER_ARCHITECTURE.md环境规划与常量planning.ts导入保护机制INTERNALS-import-protection.md、import-protection.ts服务端函数拆分查询参数与 RSC layer 规则plugin.ts解析器清单生成与排序逻辑server-fn-resolver-module.ts服务端函数改写实现handleCreateServerFn.ts元数据键与类型定义start-compiler-metadata.ts【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考