模块加载与运行时刷新 深度解析
这页拆 Rome 如何在 Node ESM、tsx loader、app action 和 env 变化之间保持热刷新可控。
导入链与契约
packages/core/src/actions/module-loader.ts:9 到 packages/core/src/actions/module-loader.ts:26 的注释解释了问题根因:entry URL 的 query 会被 Node/tsx 路径归一化破坏,helper 模块可能继续使用旧缓存。packages/core/src/actions/module-loader.ts:27 定义 salt,packages/core/src/actions/module-loader.ts:34 注册 hook,packages/core/src/actions/module-loader.ts:53 接管带 query 的 file URL load,packages/core/src/actions/module-loader.ts:121 到 packages/core/src/actions/module-loader.ts:138 用 envEpoch 组成 cache key。
9// Node's ESM resolver drops the query string when a module resolves its own 10// static imports, so the ?v= cache-buster on an entry URL re-evaluates only 11// the entry file: a helper it imports keeps its unsalted URL and stays cached 12// with whatever it captured at module scope. These hooks give the entry's 13// whole own-file graph one identity per (file stats, env epoch): 14// 15// - resolve: short-circuits already-salted file URLs before any other loader 16// sees them (tsx — the `source` container mode — otherwise normalizes the 17// query away, collapsing every epoch onto one cached instance), and 18// propagates the parent's salt onto relative imports. 19// - load: serves salted URLs from disk itself, because downstream loaders 20// fail on file URLs that carry a query. 21// 22// Only compiled `.js`/`.mjs` files are salted: TypeScript app sources need 23// tsx's transform pipeline, which the bypass would skip — source-mode 24// workspace apps keep their existing repack-or-restart reload story. Bare 25// specifiers stay untouched on purpose: node_modules must resolve to the one 26// shared instance per process. 27const SALT_QUERY = /\?v=[^&]*$/; 28 29function isSaltableUrl(url: string): boolean { 30 const path = url.split("?")[0]; 31 return path.endsWith(".js") || path.endsWith(".mjs"); 32} 33 34registerHooks({ 35 resolve(specifier, context, nextResolve) { 36 if (specifier.startsWith("file:") && SALT_QUERY.test(specifier) && isSaltableUrl(specifier)) { 37 return { url: specifier, format: "module", shortCircuit: true }; 38 } 39 const result = nextResolve(specifier, context); 40 if ( 41 (specifier.startsWith("./") || specifier.startsWith("../")) && 42 context.parentURL !== undefined && 43 SALT_QUERY.test(context.parentURL) && 44 result.url.startsWith("file:") && 45 !result.url.includes("?") && 46 isSaltableUrl(result.url) 47 ) { 48 const salt = new URL(context.parentURL).searchParams.get("v"); 49 if (salt) return { url: `${result.url}?v=${salt}`, format: "module", shortCircuit: true }; 50 } 51 return result; 52 }, 53 load(url, context, nextLoad) { 54 if (url.startsWith("file:") && SALT_QUERY.test(url) && isSaltableUrl(url)) { 55 const cleanUrl = new URL(url); 56 cleanUrl.search = ""; 57 return { 58 format: "module", 59 source: readFileSync(fileURLToPath(cleanUrl)), 60 shortCircuit: true, 61 }; 62 } 63 return nextLoad(url, context); 64 }, 65});
核心实现,逐段拆
▸Entry 解析与逃逸保护
packages/core/src/actions/module-loader.ts:67 到 packages/core/src/actions/module-loader.ts:85 让 resolver 同时理解 js/ts/mjs/cjs/tsx 等扩展,packages/core/src/actions/module-loader.ts:101 到 packages/core/src/actions/module-loader.ts:118 则用 realpath 和 relative 检查 entry 不能逃出 actionDir。这个边界对 app marketplace 很关键。
67// Apps written in TypeScript declare `entry: ./index.ts` against their `src/` 68// layout; `rome build` emits `.js` into `dist/` and copies the YAML unchanged. 69// `extensionAlias` lets the resolver try the compiled `.js` sibling first and 70// fall back to `.ts` when running against source (e.g. `tsx` on the host). 71// Node's own resolver doesn't know about TypeScript extensions. 72const sharedResolver = new ResolverFactory({ 73 extensions: [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json"], 74 extensionAlias: { 75 ".js": [".js", ".ts"], 76 ".jsx": [".jsx", ".tsx"], 77 ".mjs": [".mjs", ".mts"], 78 ".cjs": [".cjs", ".cts"], 79 ".ts": [".js", ".ts"], 80 ".tsx": [".jsx", ".tsx"], 81 ".mts": [".mjs", ".mts"], 82 ".cts": [".cjs", ".cts"], 83 }, 84 mainFiles: ["index"], 85}); 86 87export type GenericActionFactory<TDeps = unknown> = (config: ActionConfig, deps: TDeps) => Action; 88 89function isActionFactory<TDeps>(value: unknown): value is GenericActionFactory<TDeps> { 90 return typeof value === "function"; 91} 92 93function toPascalCase(name: string): string { 94 return name 95 .split(/[^a-zA-Z0-9]+/) 96 .filter(Boolean) 97 .map((part) => part[0].toUpperCase() + part.slice(1)) 98 .join(""); 99} 100 101export async function resolveModuleEntryPath(baseDir: string, entry?: string): Promise<string> { 102 const absoluteBaseDir = resolve(baseDir); 103 const specifier = entry ?? "./index"; 104 const result = await sharedResolver.async(absoluteBaseDir, specifier); 105 if (!result.path) { 106 throw new Error( 107 `No entry module found for "${baseDir}" (resolving "${specifier}"): ${result.error ?? "no path returned"}`, 108 ); 109 } 110 const [realBaseDir, realEntryPath] = await Promise.all([ 111 realpath(absoluteBaseDir), 112 realpath(result.path), 113 ]); 114 const relativeEntryPath = relative(realBaseDir, realEntryPath); 115 if (relativeEntryPath.startsWith("..") || isAbsolute(relativeEntryPath)) { 116 throw new Error(`Invalid entry path "${specifier}" for ${baseDir}: escapes base dir`); 117 } 118 return result.path;
▸Factory 选择
packages/core/src/actions/module-loader.ts:140 到 packages/core/src/actions/module-loader.ts:168 把 action factory 的可接受形状写死在一处:createAction、createNameAction、default 或唯一 create*Action。错误消息绑定 packages/core/src/actions/module-loader.ts:166,所以 bad app 不会沉默失败。
140export function resolveActionFactory<TDeps>( 141 module: Record<string, unknown>, 142 actionName: string, 143): GenericActionFactory<TDeps> { 144 const preferredNamed = `create${toPascalCase(actionName)}Action`; 145 146 if (isActionFactory<TDeps>(module.createAction)) { 147 return module.createAction; 148 } 149 if (isActionFactory<TDeps>(module[preferredNamed])) { 150 return module[preferredNamed]; 151 } 152 if (isActionFactory<TDeps>(module.default)) { 153 return module.default; 154 } 155 156 const candidates = Object.entries(module) 157 .filter( 158 ([name, value]) => /^create[A-Za-z0-9_]*Action$/.test(name) && isActionFactory<TDeps>(value), 159 ) 160 .map(([, value]) => value as GenericActionFactory<TDeps>); 161 162 if (candidates.length === 1) { 163 return candidates[0]; 164 } 165 166 throw new Error( 167 `No action factory found for "${actionName}". Export createAction(config, deps) or a single create*Action function.`, 168 ); 169} 170 171export async function instantiateActionFromDirectory<TDeps>( 172 config: ActionConfig, 173 actionDir: string, 174 deps: TDeps, 175): Promise<Action> { 176 const entryPath = await resolveModuleEntryPath(actionDir, config.entry); 177 const module = await importModuleWithCacheBuster(entryPath); 178 const factory = resolveActionFactory<TDeps>(module, config.name); 179 return factory(config, deps); 180}
▸Catalog 事件重注册
packages/core/src/actions/app-actions-wiring.ts:127 到 packages/core/src/actions/app-actions-wiring.ts:159 的 lazy action 保留第一次执行时再实例化的路径;packages/core/src/actions/app-actions-wiring.ts:162 到 packages/core/src/actions/app-actions-wiring.ts:197 的 eager 注册会收集 loaded/failed;packages/core/src/actions/app-actions-wiring.ts:242 到 packages/core/src/actions/app-actions-wiring.ts:250 在 app 变化时先 unregisterOwnedBy 再只重载该 app。
127function createLazyAppAction( 128 record: AppActionRecord, 129 catalog: AppCatalog, 130 deps: Record<string, unknown>, 131 services: AppActionServices, 132): Action { 133 let actionPromise: Promise<Action> | null = null; 134 135 const load = async (): Promise<Action> => { 136 if (!actionPromise) { 137 actionPromise = instantiateActionFromDirectory( 138 record.config, 139 record.directory, 140 createAppActionRuntimeDeps(record, catalog, deps, services), 141 ).catch((err) => { 142 actionPromise = null; 143 throw err; 144 }); 145 } 146 return actionPromise; 147 }; 148 149 return { 150 config: record.config, 151 async execute(args: Record<string, unknown>, context?: ActionExecutionContext) { 152 const action = await load(); 153 return action.execute(args, context); 154 }, 155 async preview(args: Record<string, unknown>) { 156 const action = await load(); 157 return action.preview?.(args); 158 }, 159 }; 160} 161 162export async function registerAppActions( 163 actionLoader: ActionLoader, 164 actionRegistry: ActionRegistryImpl, 165 catalog: AppCatalog, 166 deps: Record<string, unknown>, 167 services: AppActionServices, 168 options: { onlyAppId?: string } = {}, 169): Promise<{ loaded: string[]; failed: AppActionLoadFailure[] }> { 170 const loaded: string[] = []; 171 const failed: AppActionLoadFailure[] = []; 172 173 for (const [name, record] of actionLoader.getAllRecords()) { 174 if (record.metadata.ownerType !== "app") { 175 continue; 176 } 177 if (options.onlyAppId !== undefined && record.metadata.ownerId !== options.onlyAppId) { 178 continue; 179 } 180 181 try { 182 const action = await instantiateActionFromDirectory(record.config, record.directory, { 183 ...createAppActionRuntimeDeps(record, catalog, deps, services), 184 }); 185 actionRegistry.register(action, record.metadata); 186 loaded.push(name); 187 } catch (err) { 188 failed.push({ 189 name, 190 ownerId: record.metadata.ownerId, 191 error: err instanceof Error ? err.message : String(err), 192 }); 193 } 194 } 195 196 return { loaded, failed }; 197} 198 199export function registerLazyAppActions( 200 actionLoader: ActionLoader, 201 actionRegistry: ActionRegistryImpl, 202 catalog: AppCatalog, 203 deps: Record<string, unknown>, 204 services: AppActionServices, 205 options: { onlyAppId?: string } = {}, 206): { loaded: string[]; failed: AppActionLoadFailure[] } { 207 const loaded: string[] = []; 208 const failed: AppActionLoadFailure[] = []; 209 210 for (const [name, record] of actionLoader.getAllRecords()) { 211 if (record.metadata.ownerType !== "app") { 212 continue; 213 } 214 if (options.onlyAppId !== undefined && record.metadata.ownerId !== options.onlyAppId) { 215 continue; 216 } 217 218 actionRegistry.register(createLazyAppAction(record, catalog, deps, services), record.metadata); 219 loaded.push(name); 220 } 221 222 return { loaded, failed }; 223}
关键流程
catalog event → unregister app actions → instantiateActionFromDirectory → resolveModuleEntryPath → importModuleWithCacheBuster(mtime-size-envEpoch) → resolveActionFactory → registry.register
packages/core/src/actions/app-actions-wiring.ts:181、packages/core/src/actions/app-actions-wiring.ts:185、packages/core/src/actions/app-actions-wiring.ts:188、packages/core/src/actions/app-actions-wiring.ts:196 展示 eager 路径如何把失败收集为 AppActionLoadFailure;这比启动时直接 throw 更适合 dashboard 展示局部坏 app。
162export async function registerAppActions( 163 actionLoader: ActionLoader, 164 actionRegistry: ActionRegistryImpl, 165 catalog: AppCatalog, 166 deps: Record<string, unknown>, 167 services: AppActionServices, 168 options: { onlyAppId?: string } = {}, 169): Promise<{ loaded: string[]; failed: AppActionLoadFailure[] }> { 170 const loaded: string[] = []; 171 const failed: AppActionLoadFailure[] = []; 172 173 for (const [name, record] of actionLoader.getAllRecords()) { 174 if (record.metadata.ownerType !== "app") { 175 continue; 176 } 177 if (options.onlyAppId !== undefined && record.metadata.ownerId !== options.onlyAppId) { 178 continue; 179 } 180 181 try { 182 const action = await instantiateActionFromDirectory(record.config, record.directory, { 183 ...createAppActionRuntimeDeps(record, catalog, deps, services), 184 }); 185 actionRegistry.register(action, record.metadata); 186 loaded.push(name); 187 } catch (err) { 188 failed.push({ 189 name, 190 ownerId: record.metadata.ownerId, 191 error: err instanceof Error ? err.message : String(err), 192 }); 193 } 194 } 195 196 return { loaded, failed }; 197}
边界 / 失败模式
packages/core/src/actions/module-loader.test.ts:11到packages/core/src/actions/module-loader.test.ts:15解释为什么测试必须跑真实 Node 进程。packages/core/src/actions/module-loader.test.ts:22到packages/core/src/actions/module-loader.test.ts:30构造 helper 捕获 env 的复现用例。packages/core/src/actions/module-loader.test.ts:39到packages/core/src/actions/module-loader.test.ts:47比较 bump 前后 import 行为。packages/core/src/actions/module-loader.test.ts:67到packages/core/src/actions/module-loader.test.ts:71断言 withoutBump 保持 unset、after 变成 v1。
19 it("re-evaluates the relative-import graph only after an epoch bump", async () => { 20 const dir = await mkdtemp(join(tmpdir(), "module-loader-epoch-")); 21 try { 22 // The env read lives in an imported helper, not the entry: the resolve 23 // hook must propagate the entry's cache salt onto the relative import 24 // or the helper stays cached across epochs. 25 await writeFile( 26 join(dir, "config.js"), 27 `export const captured = process.env.${TEST_KEY} ?? "unset";\n`, 28 "utf-8", 29 ); 30 await writeFile(join(dir, "entry.js"), `export { captured } from "./config.js";\n`, "utf-8"); 31 32 const loaderUrl = pathToFileURL( 33 resolve(dirname(fileURLToPath(import.meta.url)), "module-loader.ts"), 34 ).href; 35 const probePath = join(dir, "probe.mjs"); 36 await writeFile( 37 probePath, 38 ` 39import { bumpModuleEnvEpoch, importModuleWithCacheBuster } from ${JSON.stringify(loaderUrl)}; 40 41const entry = process.argv[2]; 42const before = await importModuleWithCacheBuster(entry); 43process.env.${TEST_KEY} = "v1"; 44const withoutBump = await importModuleWithCacheBuster(entry); 45bumpModuleEnvEpoch(); 46const after = await importModuleWithCacheBuster(entry); 47console.log(JSON.stringify({ 48 before: before.captured, 49 withoutBump: withoutBump.captured, 50 after: after.captured, 51})); 52`, 53 "utf-8", 54 ); 55 56 // Run under tsx — both the `source` container mode and the forked action 57 // workers in dev use it, and it is the loader that normalizes queries 58 // away without the short-circuit hook. (Plain node, the `compiled` mode, 59 // exercises the same hooks minus the tsx chain.) 60 const coreRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); 61 const { stdout } = await execFileAsync( 62 process.execPath, 63 ["--import", "tsx", probePath, join(dir, "entry.js")], 64 { cwd: coreRoot }, 65 ); 66 const lines = stdout.trim().split("\n"); 67 expect(JSON.parse(lines[lines.length - 1])).toEqual({ 68 before: "unset", 69 withoutBump: "unset", 70 after: "v1", 71 }); 72 } finally { 73 await rm(dir, { recursive: true, force: true }); 74 } 75 }, 30_000);