§ · Nested detail page

模块加载与运行时刷新 深度解析

这页拆 Rome 如何在 Node ESM、tsx loader、app action 和 env 变化之间保持热刷新可控。

§ 00 · IMPORT CHAIN

导入链与契约

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。

packages/core/src/actions/module-loader.ts · L9-65TypeScript
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});
§ 01 · CORE IMPL

核心实现,逐段拆

▸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 很关键。

packages/core/src/actions/module-loader.ts · L67-118TypeScript
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 不会沉默失败。

packages/core/src/actions/module-loader.ts · L140-180TypeScript
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。

packages/core/src/actions/app-actions-wiring.ts · L127-223TypeScript
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}
§ 02 · KEY FLOW

关键流程

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。

packages/core/src/actions/app-actions-wiring.ts · L162-197TypeScript
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}
§ 03 · EDGE CASES

边界 / 失败模式

  • 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。
packages/core/src/actions/module-loader.test.ts · L19-75TypeScript
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);
Generated · 2026-08-28Source · rome-os/rome