fix(hmr): preserve loaded entry identities across manifest changes

This commit is contained in:
imccyu committed 2026-10-02 13:54:51 +08:00
1 parent 1273855237
commit b1c5f861b6
17 files changed
+749 -77

No files matched your search

@@ -15,17 +15,17 @@
en: e72abbf8fafc89bb
zh: f6c59177696c5cd3
/agent-note-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
en: 04a76774135df963
zh: 8f84d6d9f1fdfa9a
en: c7de8dafe47a5587
zh: 6010a70d8451edd1
/agent-note-expire-hmr-package-configuration/decision/future-work:
en: 94635542451fac60
zh: d0e366a39c03c10c
en: 31818f8f7f80470b
zh: ecb7ac85bb49cf6d
/agent-note-expire-hmr-package-configuration/alternatives-considered:
en: 47590f8e9712ab5f
zh: 681162750e3b5cfb
en: 5dab1237a312169c
zh: b24927d5badedcf7
/agent-note-expire-hmr-package-configuration/verification:
en: 89f8e51419468406
zh: 372e3b02773ddd01
en: d1d931904beb1c9f
zh: ea7874b974e1c209
/agent-note-expire-hmr-package-configuration/consequences:
en: e557562e3f7b07bc
zh: 3cd196a63e4099b0
@@ -27,9 +27,13 @@ In HMR's change dispatch, a changed file named `package.json` outside `node_modu
- CommonJS `_pathCache` is cleared for the same reason. Unrelated requests recompute their resolution without unloading their modules.
- CommonJS loads using the default resolver pass the freshly resolved filename to the native loader. Its private request alias cannot select an older entry; cached module instances remain intact.
Configuration invalidation alone leaves loaded modules unchanged. Stopping and restarting a Loader entry resolves its package name again and selects the new entry. Tracking active entry URLs independently of later package-name resolutions is deferred.
Configuration invalidation alone leaves loaded modules unchanged. Loader entries retain their raw import results before export normalization. HMR matches imported Node module objects to cached ModuleJob namespaces and reloads the loaded URLs. All matching entry records, including disabled entries, update only after a successful reload and remain unchanged on failure. Stopping and restarting a Loader entry resolves its package name again and selects the new entry.
The implementation lives in `packages/boot/hmr/src/package-manifest.ts`, which encapsulates its Node internal interfaces. `index.ts` dispatches manifest changes without changing the module-reload algorithm. HMR's `node_modules` exclusion is unchanged.
Entry names are scoped by configuration-tree base URL and retain all distinct imported namespaces. An uninitialized entry contributes the name without erasing loaded namespaces; name-based resolution is used only when none is recorded, or for `cordis:` builtins. Dependency analysis considers every recorded module. Entries sharing a plugin runtime use one replacement operation, while each Loader entry selects its replacement by its original namespace. Module imports and export normalization finish before the old runtime is removed. Failed imports or activation restore the previous modules and plugin implementations and clean up partially activated replacements.
An instance without a Loader entry has no recorded module identity. If a shared runtime's replacement modules have different plugin callbacks, HMR reports an ambiguity error and rolls back instead of assigning that instance to an arbitrary module.
Package invalidation lives in `packages/boot/hmr/src/package-manifest.ts`, which encapsulates its Node internal interfaces. `index.ts` dispatches manifest changes and locates loaded entries by module identity. HMR's `node_modules` exclusion is unchanged.
| Interface | Action |
|---|---|
@@ -45,7 +49,6 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly.
- TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization.
- After exports or main moves an entry, HMR can fail to locate its old loaded module by package name. Keeping an active-entry URL association is deferred; restarting the Loader entry selects the new entry.
- CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior.
- Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred.
@@ -57,12 +60,14 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
**Re-import reloaded plugins by package name.** It would let an entry rename follow a source reload, but changes `partialReload`'s rule that the loaded URL is the reload unit. Entry renames already take effect when the Loader entry restarts, so this is not changed.
**Keep one namespace per entry name.** Entries with the same name and base URL can have loaded different modules before and after a manifest change. Keeping only the first or last namespace loses a live module; an uninitialized entry must not erase another entry's imported namespace.
## Verification
| Coverage | Location |
|---|---|
| Expired exports, main, imports, type, scope, and nearest manifests; native-reader parity; actual CommonJS loads and retained module instances; the `node_modules` boundary; restoration | `packages/boot/hmr/tests/package-manifest.spec.ts` |
| Configuration-only manifests, JSON-module and host reloads, source reloads, entry restarts, and the `node_modules` exclusion | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
| Configuration-only manifests, JSON-module and host reloads, source reloads, same-name and shared-runtime entries, import and activation rollback, entry restarts, and the `node_modules` exclusion | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
Tests need no API key and make no model calls.
@@ -27,9 +27,13 @@ HMR 的文件变化分派中,文件名为 `package.json` 且不在 `node_modul
- 基于相同原因清空 CommonJS `_pathCache`。无关请求重新计算解析结果,不卸载其模块。
- 使用默认 resolver 的 CommonJS 加载把重新解析的文件名交给原生 loader。其私有请求别名不再选中旧入口,已缓存的模块实例保持不变。
仅使配置失效不会改变已加载模块。停止并重新启动 Loader entry 时,会重新按包名解析并选择新入口。独立于后续包名解析记录活动入口 URL 留待后续。
仅使配置失效不会改变已加载模块。Loader entry 保留导出规范化之前的原始导入结果。HMR 将已导入的 Node 模块对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,重载已加载 URL。所有匹配的 entry 记录,包括已停用的 entry,仅在重载成功后更新,失败时保持不变。停止并重新启动 Loader entry 时,会重新按包名解析并选择新入口。
实现位于 `packages/boot/hmr/src/package-manifest.ts`,封装其使用的 Node internal 接口。`index.ts` 分派 manifest 变更,不改变模块重载算法。HMR 的 `node_modules` 排除规则不变。
entry 名称以配置树 base URL 为作用域,并保留所有不同的已导入命名空间。尚未初始化的 entry 只登记名称,不抹去已加载的命名空间;仅在没有记录命名空间或入口为 `cordis:` 内置模块时按名称解析。依赖分析考虑每个已记录模块。共享插件运行时的 entry 使用同一次替换操作,但每个 Loader entry 按自身原命名空间选择替换实现。模块导入与导出规范化在移除旧运行时之前完成。导入或激活失败时,恢复原来的模块和插件实现,并清理已部分激活的替换实例。
没有 Loader entry 的实例不具备已记录的模块身份。如果共享运行时的替换模块具有不同的插件回调,HMR 会报告歧义错误并回滚,而不是将该实例随意分配给某个模块。
包配置失效实现在 `packages/boot/hmr/src/package-manifest.ts`,封装其使用的 Node internal 接口。`index.ts` 分派 manifest 变更,并按模块身份定位已加载 entry。HMR 的 `node_modules` 排除规则不变。
| 接口 | 动作 |
|---|---|
@@ -45,7 +49,6 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。
- 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。
- exports 或 main 改变入口后,HMR 可能无法按包名找到旧的已加载模块。保留活动 entry URL 关联留待后续;重启 Loader entry 会选择新入口。
- 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。
- 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。
@@ -57,12 +60,14 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
**按插件名重新 import 被重载的插件。** 这能让入口改名随源码重载生效,但会改变 `partialReload` 以已加载 URL 为重载单位的规则。入口改名在 Loader entry 重启时已经生效,所以本次不改。
**每个 entry 名称只保留一个命名空间。** 名称和 base URL 相同的 entry,可能在 manifest 变化前后加载了不同模块。只保留最早或最后一个命名空间会遗漏仍在使用的模块;尚未初始化的 entry 也不能抹去其他 entry 的已导入命名空间。
## Verification
| 覆盖面 | 位置 |
|---|---|
| 失效后的 exports、main、imports、type、scope、最近 package.json,原生 reader 对照,实际 CommonJS 加载及旧模块实例保留,node_modules 边界,恢复 | `packages/boot/hmr/tests/package-manifest.spec.ts` |
| 配置专用 manifest、JSON 模块与宿主重载、源码重载、entry 重启和 node_modules 排除规则 | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
| 配置专用 manifest、JSON 模块与宿主重载、源码重载、同名及共享运行时的 entry、导入与激活失败回滚、entry 重启和 node_modules 排除规则 | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
测试不需要 API key,不调用模型。
+1 -1
View File
@@ -1305,7 +1305,7 @@ export interface Config {
## `@deepseek-ai/dsh-hmr`
- `refs`: `ChokidarOptions` (`chokidar`)
- `source`: [`packages/boot/hmr/src/index.ts:52`](../packages/boot/hmr/src/index.ts)
- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts)
```ts config-catalog
/** Module roots and watcher timing, with Chokidar deployment options. */
+1 -1
View File
@@ -1307,7 +1307,7 @@ export interface Config {
## `@deepseek-ai/dsh-hmr`
- `refs`: `ChokidarOptions` (`chokidar`)
- `source`: [`packages/boot/hmr/src/index.ts:52`](../packages/boot/hmr/src/index.ts)
- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts)
```ts config-catalog
/** Module roots and watcher timing, with Chokidar deployment options. */
+2 -2
View File
@@ -51,8 +51,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
+2 -2
View File
@@ -53,8 +53,8 @@
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:31`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:36`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
+4 -4
View File
@@ -21,8 +21,8 @@
en: 45ee194ad781d603
zh: b3d7fcd8ed73717c
/deepseek-ai-dsh-hmr/understand-the-implementation:
en: 62af58987aad9ad3
zh: ef66c9a8fb24b6cb
en: c4a270a9970e7ee9
zh: 700c4ddacb5d0333
/deepseek-ai-dsh-hmr/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -39,8 +39,8 @@
en: 89e0624fafd701fb
zh: 19d36a33c1d9af9d
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work:
en: bc0c2ea681297be5
zh: 73adddd67dcc50a3
en: 0b1966d667d1830d
zh: 0c4b7ab863ef24bb
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note:
en: 7972e4b2005ec9a4
zh: 754c49ec538c164c
+5 -1
View File
@@ -60,6 +60,10 @@ App-boot owns profile parsing and patch precedence. HMR reads the launcher’s d
A changed `package.json` outside `node_modules` expires that package's cached configuration. A configuration-only manifest triggers no module reload; a manifest imported as a JSON module retains dependency-driven reloads, and one in the host dependency graph retains host reloads. Dedicated configuration watches keep their existing ownership. A new package entry takes effect when its Loader entry restarts.
Loader entries retain their raw import results. HMR retains every distinct imported namespace for each entry name and configuration-tree base URL; an uninitialized entry does not overwrite another entry's namespace. HMR matches those objects to cached ModuleJob namespaces instead of resolving entry names again. Names without an imported namespace and `cordis:` builtins keep name-based resolution. Source reloads therefore use the loaded modules after `exports` or `main` changes.
Entries sharing a runtime are replaced together, with each entry receiving the implementation for its own module. Duplicate entries for one module retain their separate plugin instances. On replacement failure, HMR removes partially activated replacements and restores the previous modules and plugin implementations. A successful reload updates every matching entry record, including disabled entries; a failed replacement leaves those records unchanged. Entries that have never loaded remain uninitialized.
[package-manifest.ts](src/package-manifest.ts) owns the Node internal interfaces for package reads, scope, type, and nearest-manifest lookups. Each HMR instance owns its configuration cache and restores its installed hooks on disposal. Manifest invalidation clears the ESM `ResolveCache` and CommonJS `_pathCache`: neither records every consulted manifest, and entries may point outside their package directory. With Node's default resolver, CommonJS loads resolve the request to a filename before entering the native loader, bypassing its old request alias while preserving evaluated modules. Ordinary module replacement remains HMR's separate operation.
Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
@@ -92,9 +96,9 @@ Reloading a contributing plugin can change later request prefixes; HMR does not
- Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process.
- Replacing installed package versions still requires a restart through Plugin Manager; manifests below `node_modules` keep Node's cached configuration. The browser Client module graph retains its separate browser-side loading mechanism.
- Future work: support TSX's asynchronous loader thread. Main-thread hooks do not invalidate that thread's package configuration; ordinary application-created Workers are outside the supported scope.
- Future work: retain active entry URLs after `exports` or `main` changes. HMR still resolves entry names when locating loaded modules; after an entry moves, source changes may not reload the old entry until the Loader entry restarts.
- CommonJS requests with registered synchronous resolve hooks retain the original loader path; refreshing their private request cache is deferred. Builtins retain Node's original loading behavior.
- Future work: retain package invalidation across HMR disposal and replacement. Restoring the native readers can expose their older cached configuration again.
- If a shared runtime's replacement modules have different plugin callbacks, an instance without a Loader entry cannot be assigned to one of them. HMR reports an ambiguity error and rolls back the reload.
- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected.
### Dev Note
+5 -1
View File
@@ -60,6 +60,10 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
`node_modules` 之外的 `package.json` 变化使该包的配置缓存失效。仅作为配置的 manifest 不触发模块重载;作为 JSON 模块导入的 manifest 保留依赖驱动重载,位于宿主依赖图中的 manifest 保留宿主重载。专属配置监听保留原有归属。新包入口在 Loader entry 重启时生效。
Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 base URL 保留所有不同的已导入命名空间;尚未初始化的 entry 不会覆盖其他 entry 的命名空间。HMR 将这些对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,不再重新解析 entry 名称。没有已导入命名空间的名称和 `cordis:` 内置入口仍按名称解析。因此 `exports` 或 `main` 改变后,源码重载仍使用已加载模块。
共享同一运行时的 entry 一起替换,每个 entry 使用其自身模块对应的实现。同一模块的重复 entry 仍保有各自的插件实例。替换失败时,HMR 清理已部分激活的替换实例,并恢复原来的模块和插件实现。重载成功后更新所有匹配的 entry 记录,包括已停用的 entry;替换失败时这些记录保持不变。从未加载过的 entry 仍保持未初始化状态。
[package-manifest.ts](src/package-manifest.ts) 封装 package 读取、scope、type 和最近 manifest 查询所需的 Node internal 接口。每个 HMR 实例拥有自己的配置缓存,销毁时恢复它安装的 hook。manifest 失效会清空 ESM `ResolveCache` 和 CommonJS `_pathCache`:它们都不记录查询过的全部 manifest,入口也可能指向包目录之外。使用 Node 默认 resolver 时,CommonJS 加载先将请求解析为文件名,再进入原生 loader,绕过旧请求别名并保留已求值模块。普通模块替换仍由 HMR 独立处理。
被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
@@ -92,9 +96,9 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
- 通过插件管理器替换已安装包版本仍需要重启;`node_modules` 之下的 manifest 保留 Node 缓存的配置。浏览器 Client 模块图保留独立的浏览器侧加载机制。
- 后续工作:支持 TSX 的异步 loader 线程。主线程 hook 不会使该线程内的包配置失效;普通应用自行创建的 Worker 不在支持范围内。
- 后续工作:在 `exports` 或 `main` 改变后保留活动 entry URL。HMR 定位已加载模块时仍重新解析 entry 名称;入口移动后,源码变化可能无法重载旧入口,需要先重启 Loader entry。
- 注册了同步 resolve hooks 的 CommonJS 请求保留原有 loader 路径,其私有请求缓存刷新留待后续。内置模块保留 Node 原有的加载行为。
- 后续工作:跨 HMR 销毁和替换保留包配置失效状态。恢复原生 reader 后,其旧配置缓存可能重新可见。
- 如果共享运行时的替换模块具有不同的插件回调,没有 Loader entry 的实例就无法确定应使用哪一个。HMR 会报告歧义错误并回滚此次重载。
- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。
### 开发备注
+178 -36
View File
@@ -1,8 +1,9 @@
/** Serialized module and profile-configuration reloads. */
import assert from 'node:assert/strict'
import { AsyncLocalStorage } from 'node:async_hooks'
import { watchConfig as watchExactConfig } from './watch-config.ts'
import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
import { Context, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis'
import type { ModuleLoader, ModuleJob, ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
import type { Include } from '@deepseek-ai/cordis-plugin-include'
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
@@ -80,9 +81,129 @@ async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
/** Module location and runtime retained during a replacement. */
export interface Reload {
filename: string
/** Original namespaces and URLs of all entry modules participating in this runtime replacement. */
modules: ReloadModules
runtime?: Plugin.Runtime | undefined
}
/** Entry names with distinct namespaces; iteration falls back to undefined only for names without a loaded namespace. */
class EntryNamespaces {
private readonly names = new Map<string, Set<unknown>>()
constructor(private internal: ModuleLoader, private moduleUrls: ReadonlyMap<unknown, string>) {}
static getModuleUrls(internal: ModuleLoader): Map<unknown, string> {
const moduleUrls = new Map<unknown, string>()
for (const url of internal.loadCache.keys()) {
const job = internal.loadCache.get(url)
if (!job?.module) continue
try {
moduleUrls.set(job.module.getNamespace(), url)
} catch (_error) {
// Node also caches unfinished jobs whose namespace is not available yet.
}
}
return moduleUrls
}
add(name: string, moduleNamespace: unknown): this {
const namespaces = this.names.get(name) ?? new Set<unknown>()
namespaces.add(moduleNamespace)
this.names.set(name, namespaces)
return this
}
*[Symbol.iterator](): Generator<[name: string, moduleNamespace: unknown]> {
for (const [name, namespaces] of this.names) {
for (const moduleNamespace of namespaces) {
if (moduleNamespace === undefined && namespaces.size > 1) continue
yield [name, moduleNamespace]
}
}
}
async resolve(name: string, baseUrl: string, moduleNamespace: unknown): Promise<Pick<ResolveResult, 'url'>> {
if (moduleNamespace === undefined || name.startsWith('cordis:')) {
switch (this.internal.version) {
case 'v1': return await this.internal.resolve(name, baseUrl, {})
case 'v2': return this.internal.resolveSync(baseUrl, { specifier: name, attributes: {} })
}
}
const url = this.moduleUrls.get(moduleNamespace)
if (url === undefined) throw new Error(`HMR cannot locate the loaded module for ${name} from ${baseUrl}`)
return { url }
}
}
type ReloadFiber = {
fiber: Fiber
entry: Fiber['entry']
config: unknown
moduleNamespace: unknown
}
type ReloadModule = { filename: string; moduleNamespace: unknown; plugin: Plugin }
/** Entry modules belonging to one runtime, keyed by their original namespaces across replacement. */
class ReloadModules {
private readonly modules = new Map<unknown, ReloadModule>()
static include(reloads: ReadonlyMap<Plugin, Reload>, plugin: Plugin, job: ModuleJob, runtime: Plugin.Runtime | undefined): boolean {
let info = reloads.get(plugin)
if (info === undefined && runtime) info = [...reloads.values()].find(info => info.runtime === runtime)
if (info === undefined) return false
info.modules.add(job, plugin)
return true
}
add(job: ModuleJob, plugin: Plugin): this {
assert(job.module, `HMR pending module is missing: ${job.url}`)
const moduleNamespace: unknown = job.module.getNamespace()
this.modules.set(moduleNamespace, { filename: job.url, moduleNamespace, plugin })
return this
}
async importRemaining(loader: Context['loader'], getOuterStack: () => string[], primary: ReloadModule): Promise<ReloadModules> {
const replacements = new ReloadModules()
for (const [originalNamespace, { filename }] of this.modules) {
if (filename === primary.filename) {
replacements.modules.set(originalNamespace, primary)
continue
}
const moduleNamespace: unknown = await loader.import(filename, getOuterStack)
const plugin = loader.unwrapExports(moduleNamespace) as Plugin
replacements.modules.set(originalNamespace, { filename, moduleNamespace, plugin })
}
return replacements
}
*getActiveImplementations(ctx: Context, plugin: Plugin, fibers: readonly ReloadFiber[]): Generator<[ReloadFiber, Plugin]> {
const callbacks = new Set([...this.modules.values()].map(value => ctx.registry.resolve(value.plugin)))
const implementations = fibers.map((previousFiber) => {
if (previousFiber.entry === undefined) return [previousFiber, plugin] as const
const replacement = this.modules.get(previousFiber.moduleNamespace)
const implementation = replacement ? replacement.plugin : ctx.loader.unwrapExports(previousFiber.moduleNamespace) as Plugin
callbacks.add(ctx.registry.resolve(implementation))
return [previousFiber, implementation] as const
})
for (const [previousFiber, implementation] of implementations) {
if (previousFiber.fiber.parent.fiber.uid === null) continue
if (previousFiber.entry === undefined && callbacks.size > 1) {
throw new Error('HMR replacement is ambiguous for a plugin instance without a Loader entry')
}
yield [previousFiber, implementation]
}
}
updateEntries(loader: Context['loader']): void {
for (const [originalNamespace, { moduleNamespace }] of this.modules) {
for (const entry of loader.entries()) {
if (entry.moduleNamespace === originalNamespace) entry.moduleNamespace = moduleNamespace
}
}
}
}
/** Hot reload service with Cordis-compatible module configuration and events. */
@Inject('loader')
@Inject('timer')
@@ -187,16 +308,6 @@ class Hmr extends Service {
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
}
/**
* Resolve a module specifier to a URL, compatible with Node 22-24.
*/
private async _resolve(specifier: string, parentURL: string, attrs: ImportAttributes): Promise<ResolveResult> {
switch (this.internal.version) {
case 'v1': return await this.internal.resolve(specifier, parentURL, attrs)
case 'v2': return this.internal.resolveSync(parentURL, { specifier, attributes: attrs })
}
}
async* [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown> {
yield async () => {
this.closing = true
@@ -416,23 +527,24 @@ class Hmr extends Service {
const pending = new Map<ModuleJob, Plugin>()
const reloads = new Map<Plugin, Reload>()
const moduleUrls = EntryNamespaces.getModuleUrls(this.internal)
// Build a map of plugin names per config tree URL.
// Plugin entry files are treated as atomic reload units.
const nameMap = new Map<string, Set<string>>()
const nameMap = new Map<string, EntryNamespaces>()
for (const entry of this.ctx.loader.entries()) {
const baseUrl = entry.parent.tree.ctx.baseUrl
if (baseUrl === undefined) throw new Error('HMR entry tree has no base URL')
const names = nameMap.get(baseUrl) ?? new Set<string>()
names.add(entry.options.name)
const names = nameMap.get(baseUrl) ?? new EntryNamespaces(this.internal, moduleUrls)
names.add(entry.options.name, entry.moduleNamespace)
nameMap.set(baseUrl, names)
}
// Resolve each plugin name to its file URL and check if it needs reload
// Find each plugin's loaded URL and check if it needs reload.
for (const [baseUrl, names] of nameMap) {
for (const name of names) {
for (const [name, moduleNamespace] of names) {
try {
const { url } = await this._resolve(name, baseUrl, {})
const { url } = await names.resolve(name, baseUrl, moduleNamespace)
if (this.declined.has(url)) continue
const job = this.internal.loadCache.get(url)
const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace()) as Plugin | undefined
@@ -451,15 +563,27 @@ class Hmr extends Service {
const dependencies = [...await loadDependencies(job, this.declined)]
this.declined.add(job.url)
if (dependencies.length === 0) {
pending.delete(job)
continue
}
if (!dependencies.some(dep => this.accepted.has(dep))) continue
dependencies.forEach(dep => this.accepted.add(dep))
const runtime = this.ctx.registry.get(plugin)
if (ReloadModules.include(reloads, plugin, job, runtime)) continue
reloads.set(plugin, {
filename: job.url,
runtime: this.ctx.registry.get(plugin),
runtime,
modules: new ReloadModules().add(job, plugin),
})
}
// Re-export roots of a replaced runtime must not retain bindings to its old modules.
for (const [job, plugin] of pending) {
if (ReloadModules.include(reloads, plugin, job, this.ctx.registry.get(plugin))) this.accepted.add(job.url)
}
/**
* Clear module caches for all accepted files before re-importing.
*
@@ -510,14 +634,23 @@ class Hmr extends Service {
fibers: [...info.runtime?.fibers ?? []].map((fiber) => {
const entry = fiber.entry?.fiber?.uid === fiber.uid ? fiber.entry : undefined
const config: unknown = entry === undefined ? fiber._config : entry.options.config
return { fiber, entry, config }
return { fiber, entry, config, moduleNamespace: entry?.moduleNamespace }
}),
}))
const attempts: Array<(typeof generations)[number] & { replacement: Plugin }> = []
type ReloadAttempt = (typeof generations)[number] & {
replacement: Plugin
replacements: ReloadModules
activated: Fiber[]
}
const attempts: ReloadAttempt[] = []
try {
for (const generation of generations) {
const replacement = this.ctx.loader.unwrapExports(await this.ctx.loader.import(generation.filename, this.getOuterStack)) as Plugin
attempts.push({ ...generation, replacement })
const moduleNamespace: unknown = await this.ctx.loader.import(generation.filename, this.getOuterStack)
const replacement = this.ctx.loader.unwrapExports(moduleNamespace) as Plugin
const replacements = await generation.modules.importRemaining(this.ctx.loader, this.getOuterStack, {
filename: generation.filename, moduleNamespace, plugin: replacement,
})
attempts.push({ ...generation, replacement, replacements, activated: [] })
}
} catch (e) {
handleError(this.ctx, e)
@@ -525,11 +658,14 @@ class Hmr extends Service {
throw e
}
const reload = async (plugin: Plugin, fibers: (typeof generations)[number]['fibers']) => {
const activated = []
for (const previousFiber of fibers) {
if (previousFiber.fiber.parent.fiber.uid === null) continue
const fiber = previousFiber.fiber.parent.registry.plugin(plugin, previousFiber.config, this.getOuterStack).ctx.fiber
const reload = async (
plugin: Plugin,
fibers: (typeof generations)[number]['fibers'],
replacements = new ReloadModules(),
activated: Fiber[] = [],
) => {
for (const [previousFiber, implementation] of replacements.getActiveImplementations(this.ctx, plugin, fibers)) {
const fiber = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack).ctx.fiber
if (previousFiber.entry !== undefined) {
fiber.entry = previousFiber.entry
previousFiber.entry.fiber = fiber
@@ -541,7 +677,7 @@ class Hmr extends Service {
const removed = new Set<Plugin>()
try {
for (const { previous: plugin, replacement, filename, runtime, fibers } of attempts) {
for (const { previous: plugin, replacement, filename, runtime, fibers, replacements, activated } of attempts) {
if (!runtime) continue
const path = relative(this.baseDir, fileURLToPath(filename))
@@ -555,7 +691,7 @@ class Hmr extends Service {
}
try {
await reload(replacement, fibers)
await reload(replacement, fibers, replacements, activated)
this.ctx.logger.info('reload plugin at %C', path)
} catch (err) {
this.ctx.logger.warn('failed to reload plugin at %C', path)
@@ -566,14 +702,17 @@ class Hmr extends Service {
} catch (error) {
// Restore caches and re-register old plugins after a replacement failure.
rollback()
for (const { previous: plugin, replacement, fibers } of attempts) {
for (const { previous: plugin, fibers, activated } of attempts) {
if (!removed.has(plugin)) continue
try {
const replacementRuntime = this.ctx.registry.get(replacement)
const replacementFibers = [...replacementRuntime?.fibers ?? []]
this.ctx.registry.delete(replacement)
// Failed startup errors remain on fibers after their teardown finishes.
await Promise.allSettled(replacementFibers.map(fiber => fiber.await()))
for (const { runtime } of activated) {
if (runtime && this.ctx.registry.get(runtime.callback) === runtime) {
const replacementFibers = [...runtime.fibers]
this.ctx.registry.delete(runtime.callback)
// Failed startup errors remain on fibers after their teardown finishes.
await Promise.allSettled(replacementFibers.map(fiber => fiber.await()))
}
}
await reload(plugin, fibers)
} catch (err) {
this.ctx.logger.warn(err)
@@ -583,6 +722,9 @@ class Hmr extends Service {
}
await this.ctx.loader.await()
for (const { replacements } of attempts) {
replacements.updateEntries(this.ctx.loader)
}
this.ctx.emit('hmr/reload', reloads)
this.stashed = new Set()
}
+1 -1
View File
@@ -328,7 +328,7 @@ it('ignores framework entries and reports unresolved entry modules', async () =>
const framework = module('framework.mjs', { apply() {} })
const uncached = module('uncached.mjs', { apply() {} })
await ctx.loader.create({ name: framework.url })
await ctx.loader.create({ name: uncached.url })
await ctx.loader.create({ name: uncached.url, disabled: true })
await ctx.loader.await()
reload.externals.add(framework.url)
resolve.mockImplementation((name) => {
@@ -1,9 +1,10 @@
/** HMR dispatch of package manifest changes through a real Loader and a controlled watcher. */
import { randomUUID } from 'node:crypto'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import { Context, type Plugin } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import { FSWatcher, type ChokidarOptions } from 'chokidar'
@@ -40,14 +41,14 @@ function manifest(entry: string, dep: string): string {
return JSON.stringify({ name: 'addon', type: 'module', exports: `./${entry}.mjs`, imports: { '#dep': `./${dep}.mjs` } })
}
async function fixture(source = plugin('a')) {
async function fixture(source = plugin('a'), options: { lateHmr?: boolean; packageJson?: string } = {}) {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-hmr-manifest-dispatch-')))
const ctx = new Context()
onTestFinished(async () => {
try { await ctx.fiber.dispose() } finally { rmSync(root, { recursive: true, force: true }) }
})
const pkg = join(root, 'plugins', 'addon')
file(join(pkg, 'package.json'), manifest('a', 'dep-1'))
file(join(pkg, 'package.json'), options.packageJson ?? manifest('a', 'dep-1'))
file(join(pkg, 'a.mjs'), source)
file(join(pkg, 'b.mjs'), plugin('b'))
file(join(pkg, 'dep-1.mjs'), 'export const value = 1')
@@ -60,15 +61,25 @@ async function fixture(source = plugin('a')) {
ctx.provide('manifestTrace', trace)
await ctx.plugin(Loader)
await ctx.plugin(Timer)
const start = watchers.length
await ctx.plugin(Hmr, { root: ['../plugins'], ignored: [], debounce: 0 })
const watcher = watchers.slice(start).at(-1)!
const startHmr = async () => {
const start = watchers.length
const fiber = (await ctx.plugin(Hmr, { root: ['../plugins'], ignored: [], debounce: 0 })).ctx.fiber
return { fiber, watcher: watchers.slice(start).at(-1)! }
}
const initialHmr = options.lateHmr ? undefined : await startHmr()
const entry = ctx.loader.resolve(await ctx.loader.create({ name: 'addon' }))
await ctx.loader.await()
let hmr = initialHmr ?? await startHmr()
const warn = vi.spyOn(ctx.logger, 'warn')
onTestFinished(() => { warn.mockRestore() })
const restartHmr = async () => {
await hmr.fiber.dispose()
hmr = await startHmr()
}
const emit = async (...paths: string[]) => {
const dispatch = vi.spyOn(ctx.hmr, 'runExclusive')
try {
for (const path of paths) watcher.emit('change', path)
for (const path of paths) hmr.watcher.emit('change', path)
await vi.waitFor(() => { expect(dispatch).toHaveBeenCalledOnce() })
const result = dispatch.mock.results[0]!
expect(result.type).toBe('return')
@@ -77,7 +88,7 @@ async function fixture(source = plugin('a')) {
dispatch.mockRestore()
}
}
return { ctx, pkg, trace, emit, entry }
return { ctx, pkg, trace, emit, entry, warn, restartHmr }
}
it('does not reload for a manifest alone and resolves the new entry when the Loader entry restarts', async () => {
@@ -161,3 +172,498 @@ it('leaves manifests below node_modules to their package lifecycle', async () =>
await f.emit(installed)
expect(invalidate).not.toHaveBeenCalled()
})
it.each(['exports', 'main'] as const)('keeps reloading the loaded entry after %s moves and selects the new entry on restart', async (field) => {
const packageJson = (entry: string, dep: string) => JSON.stringify({
name: 'addon', type: 'module', [field]: `./${entry}.mjs`, imports: { '#dep': `./${dep}.mjs` },
})
const f = await fixture(plugin('a'), { packageJson: packageJson('a', 'dep-1') })
const originalNamespace = f.entry.moduleNamespace
file(join(f.pkg, 'package.json'), packageJson('b', 'dep-2'))
await f.emit(join(f.pkg, 'package.json'))
expect(f.trace).toEqual(['start:a:1'])
expect(f.entry.moduleNamespace).toBe(originalNamespace)
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:2'])
expect(f.entry.moduleNamespace).not.toBe(originalNamespace)
expect(f.entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(join(f.pkg, 'a.mjs')).href))
await f.entry.update({ disabled: true })
await f.entry.update({ disabled: false })
await f.ctx.loader.await()
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:2', 'stop:a-edited', 'start:b:2'])
expect(f.warn).not.toHaveBeenCalled()
})
it('retains the loaded entry through repeated reloads when the new entry is already cached', async () => {
const f = await fixture()
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
await f.emit(join(f.pkg, 'package.json'))
await f.ctx.loader.import('addon')
const originalNamespace = f.entry.moduleNamespace
file(join(f.pkg, 'a.mjs'), plugin('a-first'))
await f.emit(join(f.pkg, 'a.mjs'))
const firstNamespace = f.entry.moduleNamespace
expect(firstNamespace).not.toBe(originalNamespace)
file(join(f.pkg, 'a.mjs'), plugin('a-second'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-first:2', 'stop:a-first', 'start:a-second:2'])
expect(f.warn).not.toHaveBeenCalled()
})
it.each(['apply', 'apply as default'])('retains a re-export entry using its namespace when it exports %s', async (exported) => {
const f = await fixture(`export { ${exported} } from './b.mjs';\n`)
const implementation: unknown = await f.ctx.loader.import(pathToFileURL(join(f.pkg, 'b.mjs')).href)
expect(implementation).toMatchObject({ apply: f.entry.fiber?.runtime?.callback })
expect(f.entry.moduleNamespace).not.toBe(implementation)
file(join(f.pkg, 'c.mjs'), plugin('c'))
file(join(f.pkg, 'package.json'), manifest('c', 'dep-1'))
await f.emit(join(f.pkg, 'package.json'))
file(join(f.pkg, 'dep-1.mjs'), 'export const value = 2')
await f.emit(join(f.pkg, 'dep-1.mjs'))
expect(f.trace).toEqual(['start:b:1', 'stop:b', 'start:b:2'])
expect(f.entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(join(f.pkg, 'a.mjs')).href))
await f.entry.update({ disabled: true })
await f.entry.update({ disabled: false })
await f.ctx.loader.await()
expect(f.trace).toEqual(['start:b:1', 'stop:b', 'start:b:2', 'stop:b', 'start:c:2'])
expect(f.warn).not.toHaveBeenCalled()
})
it('tracks an entry loaded before HMR starts', async () => {
const f = await fixture(plugin('a'), { lateHmr: true })
file(join(f.pkg, 'package.json'), manifest('b', 'dep-1'))
await f.emit(join(f.pkg, 'package.json'))
file(join(f.pkg, 'a.mjs'), plugin('a-late'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-late:1'])
expect(f.warn).not.toHaveBeenCalled()
})
it('keeps entry identity when HMR is recreated between source reloads', async () => {
const f = await fixture()
file(join(f.pkg, 'a.mjs'), plugin('a-first'))
await f.emit(join(f.pkg, 'a.mjs'))
const firstNamespace = f.entry.moduleNamespace
await f.restartHmr()
expect(f.entry.moduleNamespace).toBe(firstNamespace)
file(join(f.pkg, 'a.mjs'), plugin('a-second'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-first:1', 'stop:a-first', 'start:a-second:1'])
expect(f.warn).not.toHaveBeenCalled()
})
it('leaves an unimported disabled entry without a Node cache entry alone', async () => {
const f = await fixture()
const disabled = f.ctx.loader.resolve(await f.ctx.loader.create({
name: pathToFileURL(join(f.pkg, 'b.mjs')).href, disabled: true,
}))
expect(disabled.moduleNamespace).toBeUndefined()
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(disabled.moduleNamespace).toBeUndefined()
expect(disabled.fiber).toBeUndefined()
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1'])
expect(f.warn).not.toHaveBeenCalled()
})
it('leaves a cordis builtin outside the Node namespace index without reporting an error', async () => {
const f = await fixture()
const builtin = { apply: vi.fn() }
f.ctx.loader.builtins.fixture = builtin
const entry = f.ctx.loader.resolve(await f.ctx.loader.create({ name: 'cordis:fixture' }))
await f.ctx.loader.await()
expect(entry.moduleNamespace).toBe(builtin)
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(builtin.apply).toHaveBeenCalledOnce()
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1'])
expect(f.warn).not.toHaveBeenCalled()
})
it('updates disabled entry namespaces through repeated reloads without activating them', async () => {
const f = await fixture()
const originalNamespace = f.entry.moduleNamespace
await f.entry.update({ disabled: true })
await f.ctx.loader.await()
file(join(f.pkg, 'a.mjs'), plugin('disabled-first'))
await f.emit(join(f.pkg, 'a.mjs'))
const firstNamespace = f.entry.moduleNamespace
expect(firstNamespace).not.toBe(originalNamespace)
expect(firstNamespace).toBe(await f.ctx.loader.import('addon'))
expect(f.trace).toEqual(['start:a:1', 'stop:a'])
file(join(f.pkg, 'a.mjs'), plugin('disabled-second'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.trace).toEqual(['start:a:1', 'stop:a'])
expect(f.warn).not.toHaveBeenCalled()
await f.entry.update({ disabled: false })
await f.ctx.loader.await()
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:disabled-second:1'])
})
it('reports a resolution error for an unimported entry through the existing warning path', async () => {
const f = await fixture()
await f.ctx.loader.create({ name: './missing-plugin.mjs', disabled: true })
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.warn).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_MODULE_NOT_FOUND' }))
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1'])
})
it('reports a recorded namespace missing from the cache while reloading another entry', async () => {
const f = await fixture()
const originalFiber = f.entry.fiber
const originalNamespace = f.entry.moduleNamespace
await f.ctx.loader.create({ name: pathToFileURL(join(f.pkg, 'b.mjs')).href })
await f.ctx.loader.await()
expect(Map.prototype.delete.call(f.ctx.loader.internal!.loadCache, pathToFileURL(join(f.pkg, 'a.mjs')).href)).toBe(true)
file(join(f.pkg, 'b.mjs'), plugin('b-edited'))
await f.emit(join(f.pkg, 'b.mjs'))
expect(f.warn).toHaveBeenCalledWith(expect.objectContaining({
message: `HMR cannot locate the loaded module for addon from ${f.ctx.baseUrl}`,
}))
expect(f.entry.fiber).toBe(originalFiber)
expect(f.entry.moduleNamespace).toBe(originalNamespace)
expect(f.trace).toEqual(['start:a:1', 'start:b:1', 'stop:b', 'start:b-edited:1'])
})
it.each(['import', 'activation'] as const)('retains the prior namespace after replacement %s fails and can reload again', async (phase) => {
const f = await fixture()
const originalNamespace = f.entry.moduleNamespace
const originalCallback = f.entry.fiber?.runtime?.callback
const message = `replacement ${phase} failed`
file(join(f.pkg, 'a.mjs'), phase === 'import'
? `throw new Error(${JSON.stringify(message)});\n${plugin('failed')}`
: `export function apply() { throw new Error(${JSON.stringify(message)}); }`)
await expect(f.emit(join(f.pkg, 'a.mjs'))).rejects.toThrow(message)
expect(f.entry.moduleNamespace).toBe(originalNamespace)
expect(await f.ctx.loader.import('addon')).toBe(originalNamespace)
expect(f.entry.fiber?.runtime?.callback).toBe(originalCallback)
expect(f.trace).toEqual(phase === 'import' ? ['start:a:1'] : ['start:a:1', 'stop:a', 'start:a:1'])
file(join(f.pkg, 'a.mjs'), plugin('recovered'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.entry.moduleNamespace).not.toBe(originalNamespace)
expect(f.trace.at(-1)).toBe('start:recovered:1')
})
it('reloads a native CommonJS entry and retains its new import namespace', async () => {
const f = await fixture()
const filename = join(f.pkg, 'common.cjs')
const source = (version: number) => `exports.apply = ctx => {
ctx.get('manifestTrace').push('start:cjs:${version}');
ctx.effect(() => () => { ctx.get('manifestTrace').push('stop:cjs:${version}'); });
};`
file(filename, source(1))
const entry = f.ctx.loader.resolve(await f.ctx.loader.create({ name: pathToFileURL(filename).href }))
await f.ctx.loader.await()
const originalNamespace = entry.moduleNamespace
file(filename, source(2))
await f.emit(filename)
expect(entry.moduleNamespace).not.toBe(originalNamespace)
expect(entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(filename).href))
expect(f.trace).toEqual(['start:a:1', 'start:cjs:1', 'stop:cjs:1', 'start:cjs:2'])
expect(f.warn).not.toHaveBeenCalled()
})
it('handles an entry still evaluating while another entry reloads', async () => {
const f = await fixture()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const key = 'dsh-hmr-pending-entry-' + randomUUID()
Reflect.set(globalThis, key, { entered: () => { entered.resolve(undefined) }, wait: release.promise })
f.ctx.provide('releasePendingEntry', () => { release.resolve(undefined) })
const filename = join(f.pkg, 'pending.mjs')
file(filename, `const gate = globalThis[${JSON.stringify(key)}]; gate.entered(); await gate.wait;\n${plugin('pending')}`)
const loading = f.ctx.loader.create({ name: pathToFileURL(filename).href })
onTestFinished(async () => {
release.resolve(undefined)
try { await loading } finally { Reflect.deleteProperty(globalThis, key) }
})
try {
await entered.promise
file(join(f.pkg, 'a.mjs'), plugin('a-edited').replace('export function apply(ctx) {',
"export function apply(ctx) { ctx.get('releasePendingEntry')();"))
await f.emit(join(f.pkg, 'a.mjs'))
const entry = f.ctx.loader.resolve(await loading)
await f.ctx.loader.await()
expect(entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(filename).href))
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1', 'start:pending:1'])
expect(f.warn).not.toHaveBeenCalled()
} finally {
release.resolve(undefined)
await loading
}
})
it('keeps an active entry namespace when a later same-name entry has never been enabled', async () => {
const f = await fixture()
await f.ctx.loader.root.update([
...f.ctx.loader.root.data,
{ id: 'disabled-shadow', name: 'addon', disabled: true },
])
await f.ctx.loader.await()
const disabled = f.ctx.loader.resolve('disabled-shadow')
expect(disabled.moduleNamespace).toBeUndefined()
expect(disabled.fiber).toBeUndefined()
const before = [...f.trace]
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
await f.emit(join(f.pkg, 'package.json'))
expect(f.trace).toEqual(before)
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.trace).toEqual([...before, 'stop:a', 'start:a-edited:2'])
expect(disabled.fiber).toBeUndefined()
expect(f.warn).not.toHaveBeenCalled()
})
type DispatchFixture = Awaited<ReturnType<typeof fixture>>
async function addConfiguredEntry(f: DispatchFixture, id: string, name: string, disabled = false) {
const options = { id, name, disabled }
const entry = f.ctx.loader.resolve(await f.ctx.loader.create(options))
await f.ctx.loader.await()
return entry
}
function cachedNamespace(f: DispatchFixture, filename: string): unknown {
const url = pathToFileURL(join(f.pkg, filename)).href
const job = f.ctx.loader.internal!.loadCache.get(url)
expect(job?.module).toBeDefined()
return job!.module!.getNamespace()
}
function liveFibers(runtime: Plugin.Runtime | null | undefined) {
expect(runtime).toBeDefined()
expect(runtime).not.toBeNull()
return [...runtime!.fibers].filter(fiber => fiber.uid !== null)
}
async function sharedEntryFixture(kind: 'default' | 'named') {
const source = kind === 'default'
? "export { apply as default } from './b.mjs';\n"
: "export { apply } from './b.mjs';\n"
const f = await fixture(source)
const secondFile = kind === 'default' ? 'c.mjs' : 'b.mjs'
if (kind === 'default') file(join(f.pkg, secondFile), source)
const second = await addConfiguredEntry(f, 'shared-second', pathToFileURL(join(f.pkg, secondFile)).href)
const firstPlugin = f.ctx.loader.unwrapExports(f.entry.moduleNamespace) as Plugin
const secondPlugin = f.ctx.loader.unwrapExports(second.moduleNamespace) as Plugin
expect(f.entry.moduleNamespace).not.toBe(second.moduleNamespace)
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
expect(second.moduleNamespace).toBe(cachedNamespace(f, secondFile))
if (kind === 'default') expect(firstPlugin).toBe(secondPlugin)
else expect(firstPlugin).not.toBe(secondPlugin)
expect(f.entry.fiber?.runtime).toBe(second.fiber?.runtime)
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
expect(f.trace).toEqual(['start:b:1', 'start:b:1'])
return { ...f, second, secondFile, firstPlugin }
}
it('reloads both active namespaces for one package name independently after exports move', async () => {
const f = await fixture()
const firstNamespace = f.entry.moduleNamespace
const firstRuntime = f.entry.fiber?.runtime
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
await f.emit(join(f.pkg, 'package.json'))
const second = await addConfiguredEntry(f, 'active-after-move', 'addon')
const secondNamespace = second.moduleNamespace
const secondRuntime = second.fiber?.runtime
expect(firstNamespace).not.toBe(secondNamespace)
expect(firstRuntime).not.toBe(secondRuntime)
expect(liveFibers(firstRuntime)).toHaveLength(1)
expect(liveFibers(secondRuntime)).toHaveLength(1)
expect(f.trace).toEqual(['start:a:1', 'start:b:2'])
file(join(f.pkg, 'a.mjs'), plugin('a-independent'))
await f.emit(join(f.pkg, 'a.mjs'))
const reloadedFirst = f.entry.moduleNamespace
expect(reloadedFirst).not.toBe(firstNamespace)
expect(reloadedFirst).toBe(cachedNamespace(f, 'a.mjs'))
expect(second.moduleNamespace).toBe(secondNamespace)
expect(second.fiber?.runtime).toBe(secondRuntime)
expect(f.trace).toEqual(['start:a:1', 'start:b:2', 'stop:a', 'start:a-independent:2'])
file(join(f.pkg, 'b.mjs'), plugin('b-independent'))
await f.emit(join(f.pkg, 'b.mjs'))
expect(f.entry.moduleNamespace).toBe(reloadedFirst)
expect(second.moduleNamespace).not.toBe(secondNamespace)
expect(second.moduleNamespace).toBe(cachedNamespace(f, 'b.mjs'))
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(1)
expect(liveFibers(second.fiber?.runtime)).toHaveLength(1)
expect(f.trace).toEqual([
'start:a:1', 'start:b:2', 'stop:a', 'start:a-independent:2', 'stop:b', 'start:b-independent:2',
])
expect(f.warn).not.toHaveBeenCalled()
})
it('refreshes both default re-export namespaces sharing one plugin without multiplying instances', async () => {
const f = await sharedEntryFixture('default')
const firstNamespace = f.entry.moduleNamespace
const secondNamespace = f.second.moduleNamespace
const oldFibers = liveFibers(f.entry.fiber?.runtime)
const before = f.trace.length
file(join(f.pkg, 'dep-1.mjs'), 'export const value = 2')
await f.emit(join(f.pkg, 'dep-1.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
expect(f.entry.moduleNamespace).not.toBe(f.second.moduleNamespace)
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'c.mjs'))
expect(f.ctx.loader.unwrapExports(f.entry.moduleNamespace)).toBe(f.ctx.loader.unwrapExports(f.second.moduleNamespace))
expect(f.entry.fiber?.runtime).toBe(f.second.fiber?.runtime)
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
expect(oldFibers.every(fiber => fiber.uid === null)).toBe(true)
expect(f.trace.slice(before).sort()).toEqual(['start:b:2', 'start:b:2', 'stop:b', 'stop:b'])
expect(f.warn).not.toHaveBeenCalled()
})
it('refreshes named re-export and implementation namespaces sharing a callback without multiplying instances', async () => {
const f = await sharedEntryFixture('named')
const firstNamespace = f.entry.moduleNamespace
const secondNamespace = f.second.moduleNamespace
const oldFibers = liveFibers(f.entry.fiber?.runtime)
const before = f.trace.length
file(join(f.pkg, 'b.mjs'), plugin('implementation-updated'))
await f.emit(join(f.pkg, 'b.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'b.mjs'))
expect(f.ctx.loader.unwrapExports(f.entry.moduleNamespace)).not.toBe(f.ctx.loader.unwrapExports(f.second.moduleNamespace))
expect(f.entry.fiber?.runtime).toBe(f.second.fiber?.runtime)
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
expect(oldFibers.every(fiber => fiber.uid === null)).toBe(true)
expect(f.trace.slice(before).sort()).toEqual([
'start:implementation-updated:1', 'start:implementation-updated:1', 'stop:b', 'stop:b',
])
expect(f.warn).not.toHaveBeenCalled()
})
function activeTraceCount(trace: string[], marker: string): number {
return trace.filter(event => event.startsWith(`start:${marker}:`)).length
- trace.filter(event => event === `stop:${marker}`).length
}
it('assigns each entry its own replacement when a shared runtime splits into distinct callbacks', async () => {
const f = await sharedEntryFixture('default')
const firstNamespace = f.entry.moduleNamespace
const secondNamespace = f.second.moduleNamespace
const oldFibers = liveFibers(f.entry.fiber?.runtime)
const before = f.trace.length
file(join(f.pkg, 'a.mjs'), plugin('a-split'))
file(join(f.pkg, 'c.mjs'), plugin('c-split'))
await f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'c.mjs'))
expect(f.entry.moduleNamespace).toMatchObject({ apply: f.entry.fiber?.runtime?.callback })
expect(f.second.moduleNamespace).toMatchObject({ apply: f.second.fiber?.runtime?.callback })
expect(f.entry.fiber?.runtime).not.toBe(f.second.fiber?.runtime)
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(1)
expect(liveFibers(f.second.fiber?.runtime)).toHaveLength(1)
expect(oldFibers.every(fiber => fiber.uid === null)).toBe(true)
expect(f.trace.slice(before).sort()).toEqual(['start:a-split:1', 'start:c-split:1', 'stop:b', 'stop:b'])
expect(f.warn).not.toHaveBeenCalled()
})
it('restores shared entries and a programmatic instance when split replacements are ambiguous', async () => {
const f = await sharedEntryFixture('default')
const firstNamespace = f.entry.moduleNamespace
const secondNamespace = f.second.moduleNamespace
const programmatic = (await f.ctx.plugin(f.firstPlugin)).ctx.fiber
expect(programmatic.entry).toBeUndefined()
expect(liveFibers(f.ctx.registry.get(f.firstPlugin))).toHaveLength(3)
file(join(f.pkg, 'a.mjs'), plugin('a-split'))
file(join(f.pkg, 'c.mjs'), plugin('c-split'))
await expect(f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))).rejects.toThrow(
'HMR replacement is ambiguous for a plugin instance without a Loader entry',
)
expect(f.entry.moduleNamespace).toBe(firstNamespace)
expect(f.second.moduleNamespace).toBe(secondNamespace)
expect(cachedNamespace(f, 'a.mjs')).toBe(firstNamespace)
expect(cachedNamespace(f, 'c.mjs')).toBe(secondNamespace)
const restored = f.ctx.registry.get(f.firstPlugin)
const fibers = liveFibers(restored)
expect(fibers).toHaveLength(3)
expect(fibers.filter(fiber => fiber.entry === undefined)).toHaveLength(1)
expect(f.entry.fiber?.runtime).toBe(restored)
expect(f.second.fiber?.runtime).toBe(restored)
expect(activeTraceCount(f.trace, 'b')).toBe(3)
expect(activeTraceCount(f.trace, 'a-split')).toBe(0)
expect(activeTraceCount(f.trace, 'c-split')).toBe(0)
})
it.each(['import', 'activation'] as const)('restores every shared entry when one replacement %s fails and can reload again', async (phase) => {
const f = await sharedEntryFixture('default')
const firstNamespace = f.entry.moduleNamespace
const secondNamespace = f.second.moduleNamespace
const message = `shared replacement ${phase} failed`
file(join(f.pkg, 'a.mjs'), plugin('a-replacement'))
file(join(f.pkg, 'c.mjs'), phase === 'import'
? `throw new Error(${JSON.stringify(message)});\n${plugin('c-failed')}`
: `export function apply() { throw new Error(${JSON.stringify(message)}); }`)
await expect(f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))).rejects.toThrow(message)
expect(f.entry.moduleNamespace).toBe(firstNamespace)
expect(f.second.moduleNamespace).toBe(secondNamespace)
expect(cachedNamespace(f, 'a.mjs')).toBe(firstNamespace)
expect(cachedNamespace(f, 'c.mjs')).toBe(secondNamespace)
const restored = f.ctx.registry.get(f.firstPlugin)
expect(f.entry.fiber?.runtime).toBe(restored)
expect(f.second.fiber?.runtime).toBe(restored)
expect(liveFibers(restored)).toHaveLength(2)
expect(activeTraceCount(f.trace, 'b')).toBe(2)
expect(activeTraceCount(f.trace, 'a-replacement')).toBe(0)
expect(activeTraceCount(f.trace, 'c-failed')).toBe(0)
file(join(f.pkg, 'c.mjs'), plugin('c-recovered'))
await f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'c.mjs'))
expect(f.entry.moduleNamespace).toMatchObject({ apply: f.entry.fiber?.runtime?.callback })
expect(f.second.moduleNamespace).toMatchObject({ apply: f.second.fiber?.runtime?.callback })
expect(f.entry.fiber?.runtime).not.toBe(f.second.fiber?.runtime)
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(1)
expect(liveFibers(f.second.fiber?.runtime)).toHaveLength(1)
expect(activeTraceCount(f.trace, 'b')).toBe(0)
expect(activeTraceCount(f.trace, 'a-replacement')).toBe(1)
expect(activeTraceCount(f.trace, 'c-recovered')).toBe(1)
})
it('deduplicates a repeated namespace without activating a same-name uninitialized entry', async () => {
const f = await fixture()
const second = await addConfiguredEntry(f, 'duplicate-active', 'addon')
const disabled = await addConfiguredEntry(f, 'duplicate-disabled', 'addon', true)
const originalNamespace = f.entry.moduleNamespace
expect(second.moduleNamespace).toBe(originalNamespace)
expect(disabled.moduleNamespace).toBeUndefined()
expect(disabled.fiber).toBeUndefined()
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
await f.emit(join(f.pkg, 'package.json'))
const before = f.trace.length
file(join(f.pkg, 'a.mjs'), plugin('deduplicated'))
await f.emit(join(f.pkg, 'a.mjs'))
expect(f.entry.moduleNamespace).not.toBe(originalNamespace)
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
expect(second.moduleNamespace).toBe(f.entry.moduleNamespace)
expect(f.entry.fiber?.runtime).toBe(second.fiber?.runtime)
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
expect(disabled.moduleNamespace).toBeUndefined()
expect(disabled.fiber).toBeUndefined()
expect(f.trace.slice(before).sort()).toEqual([
'start:deduplicated:2', 'start:deduplicated:2', 'stop:a', 'stop:a',
])
expect(f.warn).not.toHaveBeenCalled()
})
@@ -6164,7 +6164,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Reload',
declaration: 'export interface Reload {\n filename: string;\n runtime?: Plugin.Runtime | undefined;\n}',
declaration: 'export interface Reload {\n filename: string;\n modules: ReloadModules;\n runtime?: Plugin.Runtime | undefined;\n}',
},
{
name: 'RemoteError',
+2
View File
@@ -56,6 +56,8 @@ Keep this log exhaustive — every divergence from upstream must be listed.
22. **Volatile config across `cosmokit`, `schemastery`, `cordis`, and `loader`**: Cosmokit supplies immutable references and framework-owned commits; `deepEqual` treats two references as equal, propagates strict comparison into arrays, compares every array slot with holes treated as undefined, handles cycles conservatively, compares URLs by normalized href, and compares other opaque objects by identity in strict mode. Schemastery adds `.volatile()`, optional/default inference, placement validation without eager lazy-schema expansion (a lazy inner is validated once when built), metadata-free serialized node compatibility, serialized metadata, reference-aware simplification, unsupported volatile values reported as `ValidationError` with their path, and `.default()` accepting the raw input type `Partial<S>` because a volatile field makes the output type carry references. Loader's save path calls `simplify` with its schema receiver (`unparse.call`), an independent fix for the unbound-`this` call. Cordis exports consumer types without changing Fiber lifecycle. Loader compares raw options strictly in `Entry.update`; for a config-only change on an active fiber it calls the pure `equalExceptVolatile` function in `config/diff.ts`, which narrows the Standard Schema to Schemastery's `Schema` type (a new type-only `@deepseek-ai/schemastery` dependency of `loader`) and recursively skips fixed volatile paths using schema metadata without executing config hooks or validation. Missing or null object nodes compare using their declared defaults, including nodes without volatile descendants; schema backedges retain raw comparison. Entry passes its actual schema and preserves ordinary context and custom-update lifecycles. For a volatile-only change, `Entry._commitVolatile` re-parses the raw config through the fiber's `internal/config` hook and `resolveConfig`, compares ordinary effective values, commits references and emits the instance-local `loader/volatile-update` declared in `loader/src/index.ts`; a changed ordinary effective value falls back to the ordinary remount, and an invalid candidate is logged while the raw config stays retained for the next activation. Cosmokit provides reference enumeration and commits, and `simplify` is called with its schema receiver. The package READMEs document these additions; schema tests live in `scripts/volatile-config.spec.ts`, Loader runtime and real-file tests in `scripts/loader-volatile-update.spec.ts`, and code replacement tests in the HMR module suite.
23. **`loader/src/config/entry.ts` module identity**: retains each entry's raw import result as `moduleNamespace`, before plugin export normalization. HMR matches that object to Node's cached module namespace and updates it after a successful reload. `EntryTree.import()` keeps its existing arguments and return value; `loader/README.md` documents the additive field.
## Sync procedure
To update a vendored package from upstream:
+1
View File
@@ -41,6 +41,7 @@ root.loader.update(id, { config: { enabled: false } })
| `loader.update(id, options, parent?, position?)` | Update, move, and restart an entry. |
| `loader.remove(id)` | Stop and delete an entry. |
| `loader.resolve(id)` | Resolve an entry by id, including nested `a:b` ids. |
| `loader.resolve(id).moduleNamespace` | Raw import result before export normalization; HMR updates it after a successful reload. |
| `loader.resolveGroup(id)` | Resolve the root group or a nested group. |
| `loader.await()` | Wait for pending entry imports and fiber reloads. |
| `loader.locate(fiber?)` | Return the loader entry id that owns a fiber. |
+6 -3
View File
@@ -45,6 +45,8 @@ export class Entry {
public ctx: Context
public fiber?: Fiber
/** Raw import result before export normalization; HMR updates it after a successful reload. */
public moduleNamespace: unknown
public parent!: EntryGroup
// safety: call `entry.update()` immediately after creating an entry
public options = {} as EntryOptions
@@ -219,18 +221,19 @@ export class Entry {
}
private async _init() {
let exports: any
let moduleNamespace: unknown
try {
exports = await this.parent.tree.import(this.options.name, this.getOuterStack)
moduleNamespace = await this.parent.tree.import(this.options.name, this.getOuterStack)
} catch (error) {
this.ctx.logger.error(error)
return
} finally {
this._initTask = undefined
}
const plugin = this.loader.unwrapExports(exports)
const plugin = this.loader.unwrapExports(moduleNamespace)
this._patchContext([])
this.loader.showLog(this, 'apply')
this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack).ctx.fiber
this.moduleNamespace = moduleNamespace
}
}