@deepseek-ai/dsh-hmr 0.2.1-alpha.2 → 0.2.1-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +6 -6
- package/README.md +23 -2
- package/README.zh.md +23 -2
- package/lib/index.js +478 -50
- package/lib/types/index.d.ts +99 -0
- package/lib/types/module-files.d.ts +48 -0
- package/lib/types/watch-config.d.ts +2 -1
- package/locale/en.json +5 -0
- package/locale/zh.json +6 -0
- package/package.json +19 -17
package/README.i18n.yaml
CHANGED
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
en: 9fd376ac44e7f515
|
|
19
19
|
zh: 83bd165982e99d2a
|
|
20
20
|
/deepseek-ai-dsh-hmr/use-this-package/configuration:
|
|
21
|
-
en:
|
|
22
|
-
zh:
|
|
21
|
+
en: 11ef18c6d08f39f3
|
|
22
|
+
zh: 2911cdd03ff3747d
|
|
23
23
|
/deepseek-ai-dsh-hmr/understand-the-implementation:
|
|
24
|
-
en:
|
|
25
|
-
zh:
|
|
24
|
+
en: bd608f418630317f
|
|
25
|
+
zh: 053fa77a3fa571a9
|
|
26
26
|
/deepseek-ai-dsh-hmr/model-experience:
|
|
27
27
|
en: 215e7ba838619b7b
|
|
28
28
|
zh: a311da3843709f90
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
en: 89e0624fafd701fb
|
|
40
40
|
zh: 19d36a33c1d9af9d
|
|
41
41
|
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work:
|
|
42
|
-
en:
|
|
43
|
-
zh:
|
|
42
|
+
en: 709368e0d6a644c2
|
|
43
|
+
zh: 2d0f76c2cece79dc
|
|
44
44
|
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note:
|
|
45
45
|
en: 7972e4b2005ec9a4
|
|
46
46
|
zh: 754c49ec538c164c
|
package/README.md
CHANGED
|
@@ -46,6 +46,8 @@ Existing configurations replace the module name `@deepseek-ai/cordis-plugin-hmr`
|
|
|
46
46
|
|
|
47
47
|
Chokidar options, including polling, retain their existing meaning. Exact configuration watches also observe additions, removals and initially missing parent directories. They default to `awaitWriteFinish: true`: edits wait for Chokidar's 2-second write-stability window, avoiding its lossy change-event throttle. Configure `awaitWriteFinish` to adjust that window; disabling it can miss rapid consecutive edits. Direct Plugin Manager operations apply without waiting for file events.
|
|
48
48
|
|
|
49
|
+
Profile recompositions run by HMR's profile watches and by Plugin Manager also replace plugins whose loaded files below the profile directory changed on disk. This covers installed packages below `node_modules` and the targets of linked packages. A package whose files changed in place, such as a linked package whose build output changed or a dependency another installation upgraded, therefore takes effect without a restart at the next such recomposition; source watching through `root` is not required. A replacement that fails to load stops the previous plugin and is reported as `failed` with its load error instead of as a restart, which would load the same files; every later recomposition imports it again, so it starts once its files or dependencies are corrected.
|
|
50
|
+
|
|
49
51
|
-----
|
|
50
52
|
|
|
51
53
|
<a id="understand-the-implementation"></a>
|
|
@@ -54,10 +56,20 @@ Chokidar options, including polling, retain their existing meaning. Exact config
|
|
|
54
56
|
<details>
|
|
55
57
|
<summary>Implementation internals — click to expand</summary>
|
|
56
58
|
|
|
57
|
-
`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place.
|
|
59
|
+
`watchConfig()` registers an awaited configuration handler and watches only its target path and required parent directories; sibling files, including temporary writer locks, are excluded before native watcher creation. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place.
|
|
58
60
|
|
|
59
61
|
App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. Configuration watchers start outside the active transaction context so later notifications can enter the queue.
|
|
60
62
|
|
|
63
|
+
`reconcileProfile()` is shared by HMR’s profile watches and Plugin Manager; callers hold `runExclusive()`. Config Editor recomposes through app-boot without replacing modules. App-boot prepares and preflights the generation before caches or running entries change.
|
|
64
|
+
|
|
65
|
+
File comparison covers the profile directory and linked package targets, recorded when the application becomes ready. [ModuleFiles](src/module-files.ts) records inode, size and modification time; adding a hard link alone does not invalidate a module. Existing modules retain the identities read before replacement, preserving later writes. Newly loaded modules are recorded at settlement so a subsequent installation does not restart them merely because they are recent.
|
|
66
|
+
|
|
67
|
+
Replacement follows ES module links and every CommonJS parent’s `children`. A module's plugins are read from its ES module namespace and from the CommonJS exports Node already cached, without evaluating anything, so a CommonJS plugin registered outside a Loader entry keeps its cache. Host dependencies and plugins outside the traversable Loader tree stay pending. The remaining changed modules and their importers are evicted without a backup, and the Loader entries running affected plugins stop. HMR imports no replacement code itself: the Loader imports the current files when the generation starts an entry, so disabled, removed and incompatible rows never execute it, and dynamic `disabled` expressions and nested Includes are evaluated as at startup.
|
|
68
|
+
|
|
69
|
+
Affected runtimes stop without making their entries disabled, and `reconcileProfile()` passes them to app-boot's single `recomposeProfile()` call, which starts them, including those below an unchanged Include and those outside the profile composition, without importing an entry twice. A failure before any stop leaves every plugin running; stopped plugins start again with the current resolution when no generation applied. After eviction, a replacement that fails to import or start is an activation failure and rejects the operation; its previous code no longer runs. Every recomposition imports entries whose import failed again, after expiring the package configuration below the profile roots and evicting modules whose links failed, so a corrected file or a dependency installed later takes effect without a restart. [ProfileReconciliation](../../../docs/subsystems/boot.md#profile-reconciliation) lists changes requiring a restart. A module first loaded during a recomposition whose file was written after the comparison stays stale, so a write during activation is loaded next time; comparison or cache-inspection failure requires a restart even when no changed URL is known. HMR’s watches log the result.
|
|
70
|
+
|
|
71
|
+
After each recomposition, HMR evicts the loaded modules below the directories of the mappings the recomposition dropped and expires their package configuration, so the packages a removed or deselected bundle depends on, including those it imports dynamically, are imported afresh when it returns. A bundle package is not a mapping: its own files are replaced when they changed on disk, and a bundle switched off while its files stay unchanged keeps its modules loaded. A publication the runtime resolution rejects, such as a retained profile mapping that moved to another directory, stops and starts again the rows the generation would drop and leaves the composition as it was; the result has `restartReason: 'package-moved'`.
|
|
72
|
+
|
|
61
73
|
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.
|
|
62
74
|
|
|
63
75
|
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.
|
|
@@ -94,7 +106,16 @@ Reloading a contributing plugin can change later request prefixes; HMR does not
|
|
|
94
106
|
<a id="known-limitations-and-deferred-work"></a>
|
|
95
107
|
|
|
96
108
|
- Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process.
|
|
97
|
-
-
|
|
109
|
+
- A change confined to `package.json` is not detected. Watcher notifications for manifests below `node_modules` are still ignored. The browser Client module graph retains its separate browser-side loading mechanism.
|
|
110
|
+
- Replacement follows static imports. A dynamic `import()` of a stale module evaluates the new file on its next call while its importer keeps running; existing Workers and the TSX loader thread keep their module caches.
|
|
111
|
+
- Every symbolic link below the profile's `node_modules` widens the comparison scope. Linking a package that the process entry point, located through the real path of a bin link, imports statically, such as `dsh-app-boot`, Cordis or the Loader from a dsh checkout, brings host modules into scope; their changes stay pending, and Plugin Manager reports `restart-required` for later operations until the process restarts. Linked dsh packages that Loader entries import are replaced like other plugins.
|
|
112
|
+
- The comparison records belong to one HMR instance. A recomposition that restarts the `hmr` entry itself records the current files again, so changes pending at that point are no longer reported or retried.
|
|
113
|
+
- A replacement that fails to import or start stays inactive until its files or dependencies are corrected; the previous version is not restored.
|
|
114
|
+
- Compatibility checks run only where DSH composes rows: startup, profile recomposition and preset mounting; the Loader checks nothing when it imports a module. A replaced profile row passes the recomposition's preflight, but a row outside the profile composition, such as a preset row or an entry added through `ctx.loader.create()`, restarts with replaced code without a check. Plugin Manager refuses incompatible versions, so this affects packages changed outside it, such as a manual `pnpm add` in the profile directory or a rebuilt `link:` package.
|
|
115
|
+
- The [packed worker loader](../../experimental/webworker-runtime/src/module-system/module-loader.ts) exposes neither Node’s `loadCache` nor `require.cache`; configuration recomposition remains available, but code replacement requires a restart. Native Node retains these caches even with asynchronous module hooks. The [packed worker browser test](../../../apps/web/tests/preview-boot.e2e.ts) exercises this deployment.
|
|
116
|
+
- A newly loaded file is first recorded after recomposition settles and counts as changed when its modification time follows the comparison. The comparison time is the modification time of a marker file HMR writes in the temporary directory, so on one machine both come from the clock local file writes are stamped with; an edit within the timestamp granularity of the profile's filesystem after the comparison counts as earlier and can be missed, and network filesystems stamp writes with the server's clock.
|
|
117
|
+
- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected.
|
|
118
|
+
- Profile recomposition caused by HMR's own watches does not run for dependency-only manifest changes, so an install performed outside Plugin Manager takes effect at the next recomposition or restart.
|
|
98
119
|
- TSX loaders and ordinary application-created Workers are outside the supported package-cache refresh scope.
|
|
99
120
|
- CommonJS requests must be resolvable by Node's default resolver. Synchronous hooks may postprocess the resulting filenames, as Desktop's Office resolver does; hooks requiring the original request name or introducing virtual requests are unsupported. Builtins retain Node's original loading behavior.
|
|
100
121
|
- Future work: retain package invalidation across HMR disposal and replacement. Restoring the native readers can expose their older cached configuration again.
|
package/README.zh.md
CHANGED
|
@@ -46,6 +46,8 @@ kind: "package-reference"
|
|
|
46
46
|
|
|
47
47
|
Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时观察新增、删除及初始不存在的父目录。它们默认使用 `awaitWriteFinish: true`:编辑后等待 Chokidar 的 2 秒写入稳定窗口,避免其变化事件节流丢失通知。可通过 `awaitWriteFinish` 调整窗口;禁用它可能漏掉快速连续编辑。直接通过 Plugin Manager 发起的操作无需等待文件事件即可应用。
|
|
48
48
|
|
|
49
|
+
HMR 的 profile 监听和 Plugin Manager 执行的 profile 重新组合,还会替换 profile 目录下已加载文件在磁盘上发生变化的插件。这包括 `node_modules` 之下的已安装包,以及已链接包的目标。因此,文件被原地更改的包,例如构建输出发生变化的已链接包,或被另一次安装升级的依赖,会在下一次此类重新组合时生效,无需重启;不要求通过 `root` 监听源码。替换版本加载失败时,原插件被停止,并以 `failed` 及其加载错误报告,而不是报告为需要重启,因为重启会加载相同的文件;此后每次重新组合都会再次导入它,因此其文件或依赖被修正后它即启动。
|
|
50
|
+
|
|
49
51
|
-----
|
|
50
52
|
|
|
51
53
|
<a id="understand-the-implementation"></a>
|
|
@@ -54,10 +56,20 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
|
|
|
54
56
|
<details>
|
|
55
57
|
<summary>实现细节——点击展开</summary>
|
|
56
58
|
|
|
57
|
-
`watchConfig()`
|
|
59
|
+
`watchConfig()` 注册会被等待的配置处理器,仅监听目标路径及必要的父目录;同级文件(包括临时写锁)在创建原生 watcher 前即被排除。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。
|
|
58
60
|
|
|
59
61
|
App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。
|
|
60
62
|
|
|
63
|
+
`reconcileProfile()` 由 HMR 的 profile 监听和 Plugin Manager 共用;调用方持有 `runExclusive()`。Config Editor 通过 app-boot 重新组合,不替换模块。app-boot 在缓存或运行中条目发生变化前准备新一代组合并执行预检。
|
|
64
|
+
|
|
65
|
+
文件比较覆盖 profile 目录及已链接包的目标,在应用就绪时记录。[ModuleFiles](src/module-files.ts) 记录 inode、大小和修改时间;仅添加硬链接不会使模块失效。已有模块保留替换前读取的身份,以检测之后的写入。新加载的模块在结算时记录,因此后续安装不会仅因其文件较新而将其重启。
|
|
66
|
+
|
|
67
|
+
替换沿 ES 模块链接以及每个 CommonJS 父模块的 `children` 传播。模块的插件从其 ES 模块命名空间以及 Node 已缓存的 CommonJS exports 中读取,不求值任何代码,因此在 Loader entry 之外注册的 CommonJS 插件保留其缓存。宿主依赖及可遍历 Loader 树之外的插件保持待处理。其余已变化模块及其导入方被逐出且不保留备份,运行受影响插件的 Loader entry 被停止。HMR 本身不导入替换代码:新一代组合启动条目时,Loader 导入当前文件,因此停用、删除和不兼容的行永远不会执行替换代码,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。
|
|
68
|
+
|
|
69
|
+
受影响的运行时停止,但其条目不会被标记为停用;`reconcileProfile()` 把它们传给 app-boot 的单次 `recomposeProfile()` 调用,由该调用启动它们,包括未变化 Include 之下的条目和 profile 组合之外的条目,且不会重复导入同一条目。任何插件停止之前发生的失败会让所有插件继续运行;没有新一代组合被应用时,已停止的插件以当前 resolution 重新启动。逐出之后,导入或启动失败的替换版本属于激活失败,使操作失败;其旧代码不再运行。每次重新组合都会先让 profile 根目录下的包配置失效并逐出链接失败的模块,再次导入此前导入失败的条目,因此修正后的文件或之后安装的依赖无需重启即生效。[ProfileReconciliation](../../../docs/subsystems/boot.zh.md#profile-reconciliation) 列出需要重启的变化。在重新组合期间首次加载、且其文件在比较之后被写入的模块保持陈旧,因此激活期间的写入会在下一次加载;比较或缓存检查失败时,即使无法确定已变化的 URL,也要求重启。HMR 的监听记录这些结果。
|
|
70
|
+
|
|
71
|
+
每次重新组合之后,HMR 逐出重新组合删除的映射所在目录之下的已加载模块,并使这些包的配置过期,因此已删除或取消选入的组合包连同其动态导入的依赖,在它再次出现时重新导入。组合包的包本身不是映射:其文件在磁盘上变化时被替换;关闭组合包而其文件未变化时,它的模块保持加载。runtime resolution 拒绝的发布(例如移动到其他目录的保留 profile 映射)会停止并重新启动新一代组合将删除的行,组合保持不变;结果带有 `restartReason: 'package-moved'`。
|
|
72
|
+
|
|
61
73
|
`node_modules` 之外的 `package.json` 变化使该包的配置缓存失效。仅作为配置的 manifest 不触发模块重载;作为 JSON 模块导入的 manifest 保留依赖驱动重载,位于宿主依赖图中的 manifest 保留宿主重载。专属配置监听保留原有归属。新包入口在 Loader entry 重启时生效。
|
|
62
74
|
|
|
63
75
|
Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 base URL 保留所有不同的已导入命名空间;尚未初始化的 entry 不会覆盖其他 entry 的命名空间。HMR 将这些对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,不再重新解析 entry 名称。没有已导入命名空间的名称和 `cordis:` 内置入口仍按名称解析。因此 `exports` 或 `main` 改变后,源码重载仍使用已加载模块。
|
|
@@ -94,7 +106,16 @@ Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 b
|
|
|
94
106
|
<a id="known-limitations-and-deferred-work"></a>
|
|
95
107
|
|
|
96
108
|
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
|
|
97
|
-
-
|
|
109
|
+
- 仅限于 `package.json` 的变化不会被检测到。`node_modules` 之下 manifest 的监听通知仍被忽略。浏览器 Client 模块图保留独立的浏览器侧加载机制。
|
|
110
|
+
- 替换沿静态导入传播。对陈旧模块的动态 `import()` 在下次调用时求值新文件,而其导入方继续运行;已存在的 Worker 和 TSX loader 线程保留各自的模块缓存。
|
|
111
|
+
- profile 的 `node_modules` 之下的每个符号链接都会扩大比较范围。链接进程入口(通过 bin 链接的真实路径定位)静态导入的包(例如来自 dsh checkout 的 `dsh-app-boot`、Cordis 或 Loader)会把宿主模块纳入范围;这些模块的变化保持待处理,Plugin Manager 对后续操作报告 `restart-required`,直到进程重启。Loader entry 导入的已链接 dsh 包与其他插件一样被替换。
|
|
112
|
+
- 比较记录属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会重新记录当前文件,因此此时待处理的变化不再被报告或重试。
|
|
113
|
+
- 导入或启动失败的替换版本保持未激活,直到其文件或依赖被修正;旧版本不会被恢复。
|
|
114
|
+
- 兼容性检查只在 DSH 组合行时运行:启动、profile 重新组合和 preset 挂载;Loader 导入模块时不做任何检查。被替换的 profile 行会通过重新组合的预检,但 profile 组合之外的行(例如 preset 行,或通过 `ctx.loader.create()` 添加的条目)以替换后的代码重启,不经检查。Plugin Manager 拒绝不兼容的版本,因此这只影响在它之外变化的包,例如在 profile 目录中手动执行 `pnpm add`,或重新构建的 `link:` 包。
|
|
115
|
+
- [打包 worker loader](../../experimental/webworker-runtime/src/module-system/module-loader.ts) 不暴露 Node 的 `loadCache` 或 `require.cache`;配置重新组合仍可执行,但代码替换需要重启。原生 Node 即使使用异步模块 hook 也保留这些缓存。[打包 worker 浏览器测试](../../../apps/web/tests/preview-boot.e2e.ts) 覆盖此部署。
|
|
116
|
+
- 新加载文件首次在重新组合结算后记录,其修改时间晚于比较时间时计为已变化。比较时间是 HMR 在临时目录中写入的标记文件的修改时间,因此在同一台机器上,两者都来自为本地文件写入记时的时钟;在比较之后、处于 profile 所在文件系统时间戳精度之内的修改被视为更早,可能被漏检;网络文件系统使用服务器的时钟为写入记时。
|
|
117
|
+
- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。
|
|
118
|
+
- HMR 自身监听触发的 profile 重新组合不会因仅涉及依赖的 manifest 变化而执行,因此在 Plugin Manager 之外执行的安装在下一次重新组合或重启时生效。
|
|
98
119
|
- TSX loader 和普通应用自行创建的 Worker 不在包缓存刷新支持范围内。
|
|
99
120
|
- CommonJS 请求必须能由 Node 默认 resolver 解析。同步 hook 可以像 Desktop 的 Office resolver 一样处理解析后的文件名;依赖原始请求名或引入虚拟请求的 hook 不受支持。内置模块保留 Node 原有的加载行为。
|
|
100
121
|
- 后续工作:跨 HMR 销毁和替换保留包配置失效状态。恢复原生 reader 后,其旧配置缓存可能重新可见。
|
package/lib/index.js
CHANGED
|
@@ -5,11 +5,12 @@ import { basename, dirname, join, relative, resolve, sep, toNamespacedPath } fro
|
|
|
5
5
|
import { realpath, stat } from "node:fs/promises";
|
|
6
6
|
import { watch } from "chokidar";
|
|
7
7
|
import { Inject, Service } from "@deepseek-ai/cordis";
|
|
8
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
9
|
-
import { PROFILE_PATCH_FILENAME, readProfileManifest, readProfilePatches,
|
|
8
|
+
import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { PROFILE_PATCH_FILENAME, readProfileManifest, readProfilePatches, recomposeProfile } from "@deepseek-ai/dsh-app-boot";
|
|
10
10
|
import { codeFrameColumns } from "@babel/code-frame";
|
|
11
11
|
import { isUtf8 } from "node:buffer";
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
13
14
|
import picomatch from "picomatch";
|
|
14
15
|
import z from "@deepseek-ai/schemastery";
|
|
15
16
|
//#region lib/types/watch-config.js
|
|
@@ -35,7 +36,8 @@ async function findWatchRoot(filename) {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
/**
|
|
38
|
-
* Watch one patch path
|
|
39
|
+
* Watch one patch path and its parent chain without subscribing to sibling files or directories.
|
|
40
|
+
* Missing parents are observed as they appear; refresh callbacks run serially.
|
|
39
41
|
* @param ctx Context that owns watcher disposal and receives refresh failures.
|
|
40
42
|
* @param filename Absolute patch-file path.
|
|
41
43
|
* @param options Deployment watcher options; configuration watches enable write stabilization by default.
|
|
@@ -54,7 +56,11 @@ async function watchConfig(ctx, filename, options, refresh, inTransaction = () =
|
|
|
54
56
|
awaitWriteFinish: true,
|
|
55
57
|
...watchOptions,
|
|
56
58
|
depth: target.depth,
|
|
57
|
-
ignoreInitial: false
|
|
59
|
+
ignoreInitial: false,
|
|
60
|
+
ignored: (path) => {
|
|
61
|
+
const remainder = relative(resolve(path), target.filename);
|
|
62
|
+
return remainder === ".." || remainder.startsWith(`..${sep}`);
|
|
63
|
+
}
|
|
58
64
|
});
|
|
59
65
|
paths.add(target.filename);
|
|
60
66
|
const state = { dirty: false };
|
|
@@ -392,6 +398,112 @@ var PackageManifests = class {
|
|
|
392
398
|
}
|
|
393
399
|
};
|
|
394
400
|
//#endregion
|
|
401
|
+
//#region lib/types/module-files.js
|
|
402
|
+
/** Disk identities of loaded module files, used to find modules whose files changed after loading. */
|
|
403
|
+
/**
|
|
404
|
+
* Coarsest file timestamp resolution among supported filesystems (FAT stores modification times in 2-second units).
|
|
405
|
+
* An unrecorded file modified within this interval before the last observation is treated as possibly changed.
|
|
406
|
+
*/
|
|
407
|
+
const TIMESTAMP_RESOLUTION_MS = 2e3;
|
|
408
|
+
function identityOf(url) {
|
|
409
|
+
let stats;
|
|
410
|
+
try {
|
|
411
|
+
stats = statSync(fileURLToPath(url));
|
|
412
|
+
} catch (_error) {
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
key: `${String(stats.ino)}:${String(stats.size)}:${String(stats.mtimeMs)}`,
|
|
417
|
+
modifiedAt: stats.mtimeMs
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Records the inode, size and modification time of loaded `file:` modules. A module is stale when its file
|
|
422
|
+
* disappeared or its identity differs from the recorded one. Change time is not recorded: creating a hard link to a
|
|
423
|
+
* file, as pnpm does when it imports from its store, changes it without changing content. A module first loaded after
|
|
424
|
+
* the last observation has no record; it is stale when its file was modified at or after that observation, less the
|
|
425
|
+
* timestamp resolution, so a reinstall between loading and the next comparison is detected at the cost of reloading a
|
|
426
|
+
* module that was first loaded after such a write.
|
|
427
|
+
*/
|
|
428
|
+
var ModuleFiles = class {
|
|
429
|
+
/** Identity per recorded module; `undefined` marks a stale module, such as a pending one first loaded after the last observation. */
|
|
430
|
+
records = /* @__PURE__ */ new Map();
|
|
431
|
+
observedAt = Date.now();
|
|
432
|
+
/**
|
|
433
|
+
* The current time of the clock the operating system stamps local file writes with, read from a new marker file that is
|
|
434
|
+
* removed again. The system clock and file timestamps can differ by a clock tick in either direction, so module files
|
|
435
|
+
* are compared with this time; when no marker can be created, the system clock is used.
|
|
436
|
+
* @returns Epoch milliseconds.
|
|
437
|
+
*/
|
|
438
|
+
now() {
|
|
439
|
+
let directory;
|
|
440
|
+
try {
|
|
441
|
+
directory = mkdtempSync(join(tmpdir(), "dsh-hmr-clock-"));
|
|
442
|
+
} catch (_error) {
|
|
443
|
+
return Date.now();
|
|
444
|
+
}
|
|
445
|
+
try {
|
|
446
|
+
const marker = join(directory, "now");
|
|
447
|
+
writeFileSync(marker, "");
|
|
448
|
+
return statSync(marker).mtimeMs;
|
|
449
|
+
} finally {
|
|
450
|
+
rmSync(directory, {
|
|
451
|
+
recursive: true,
|
|
452
|
+
force: true
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Compare loaded file modules with disk.
|
|
458
|
+
* @param urls `file:` URLs of the loaded modules to compare.
|
|
459
|
+
* @returns URLs of stale modules, and the identity read for each compared URL (`undefined` for a missing file).
|
|
460
|
+
*/
|
|
461
|
+
compare(urls) {
|
|
462
|
+
const stale = /* @__PURE__ */ new Set();
|
|
463
|
+
const seen = /* @__PURE__ */ new Map();
|
|
464
|
+
for (const url of urls) {
|
|
465
|
+
const identity = identityOf(url);
|
|
466
|
+
seen.set(url, identity?.key);
|
|
467
|
+
if (identity === void 0) {
|
|
468
|
+
stale.add(url);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (this.records.has(url) ? this.records.get(url) !== identity.key : identity.modifiedAt >= this.observedAt - TIMESTAMP_RESOLUTION_MS) stale.add(url);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
stale,
|
|
475
|
+
seen
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Replace every record with the identities of the given loaded modules.
|
|
480
|
+
* @param urls `file:` URLs of the loaded modules to record.
|
|
481
|
+
* @param options `seen` supplies identities read earlier for modules whose cache entry has not changed since, so a
|
|
482
|
+
* later write stays detectable; other modules are read now, and stay stale when their file was modified after `at`.
|
|
483
|
+
* `retained` modules keep their record, or are marked stale when they have none. `at` is the observation time from
|
|
484
|
+
* {@link now} used for unrecorded modules.
|
|
485
|
+
*/
|
|
486
|
+
observe(urls, options = {}) {
|
|
487
|
+
const { seen = /* @__PURE__ */ new Map(), retained = /* @__PURE__ */ new Set(), at = this.now() } = options;
|
|
488
|
+
const records = /* @__PURE__ */ new Map();
|
|
489
|
+
for (const url of urls) if (retained.has(url)) records.set(url, this.records.get(url));
|
|
490
|
+
else if (seen.has(url)) records.set(url, seen.get(url));
|
|
491
|
+
else {
|
|
492
|
+
const identity = identityOf(url);
|
|
493
|
+
records.set(url, identity === void 0 || identity.modifiedAt > at ? void 0 : identity.key);
|
|
494
|
+
}
|
|
495
|
+
this.records = records;
|
|
496
|
+
this.observedAt = at;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Record the current identity of modules just evaluated again, leaving every other record unchanged.
|
|
500
|
+
* @param urls `file:` URLs of the re-evaluated modules.
|
|
501
|
+
*/
|
|
502
|
+
refresh(urls) {
|
|
503
|
+
for (const url of urls) this.records.set(url, identityOf(url)?.key);
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
//#endregion
|
|
395
507
|
//#region lib/types/index.js
|
|
396
508
|
/** Serialized module and profile-configuration reloads. */
|
|
397
509
|
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
@@ -444,16 +556,16 @@ function canonicalPath(filename) {
|
|
|
444
556
|
}
|
|
445
557
|
/**
|
|
446
558
|
* Recursively collect all module dependencies from a ModuleJob.
|
|
447
|
-
* Skips node: builtins and node_modules to focus on user code.
|
|
559
|
+
* Skips node: builtins, and node_modules unless `includeNodeModules` is set, to focus on user code.
|
|
448
560
|
*/
|
|
449
|
-
async function loadDependencies(job, ignored = /* @__PURE__ */ new Set()) {
|
|
561
|
+
async function loadDependencies(job, ignored = /* @__PURE__ */ new Set(), includeNodeModules = false) {
|
|
450
562
|
const dependencies = /* @__PURE__ */ new Set();
|
|
451
563
|
async function traverse(job) {
|
|
452
564
|
if (ignored.has(job.url) || dependencies.has(job.url)) return;
|
|
453
|
-
if (job.url.startsWith("node:") || job.url.includes("/node_modules/")) return;
|
|
565
|
+
if (job.url.startsWith("node:") || !includeNodeModules && job.url.includes("/node_modules/")) return;
|
|
454
566
|
dependencies.add(job.url);
|
|
455
|
-
const children = await job
|
|
456
|
-
await Promise.all(
|
|
567
|
+
const children = await linkedJobs(job);
|
|
568
|
+
await Promise.all(children.map(traverse));
|
|
457
569
|
}
|
|
458
570
|
await traverse(job);
|
|
459
571
|
return dependencies;
|
|
@@ -626,6 +738,9 @@ let Hmr = (() => {
|
|
|
626
738
|
closing = false;
|
|
627
739
|
configPaths = /* @__PURE__ */ new Set();
|
|
628
740
|
manifests = new PackageManifests();
|
|
741
|
+
files = new ModuleFiles();
|
|
742
|
+
/** Every module statically reachable from the process entry point, including those under node_modules; collected on first use. */
|
|
743
|
+
hostModules;
|
|
629
744
|
/** Serialize a caller-owned mutation with all automatic reload paths.
|
|
630
745
|
* @param operation Work that must not overlap module or configuration replacement.
|
|
631
746
|
* @returns The operation result after its asynchronous work completes.
|
|
@@ -672,7 +787,40 @@ let Hmr = (() => {
|
|
|
672
787
|
this.internal = this.ctx.loader.internal;
|
|
673
788
|
this.baseDir = fileURLToPath(new URL(config.base || ".", ctx.baseUrl));
|
|
674
789
|
}
|
|
790
|
+
/**
|
|
791
|
+
* The cached ES module job for a URL. The packed worker loader exposes no Node module cache; native Node
|
|
792
|
+
* retains this cache even when asynchronous module hooks run in a separate thread.
|
|
793
|
+
*/
|
|
794
|
+
esmJob(url) {
|
|
795
|
+
return this.hasEsmCache() ? this.internal.loadCache.get(url) : void 0;
|
|
796
|
+
}
|
|
797
|
+
/** Whether this thread keeps a Node ES module cache; the packed worker loader keeps none. */
|
|
798
|
+
hasEsmCache() {
|
|
799
|
+
return Reflect.get(this.internal, "loadCache") !== void 0;
|
|
800
|
+
}
|
|
801
|
+
/** Whether the ES module cache holds a job for a URL; `false` when this thread keeps no ES module cache. */
|
|
802
|
+
esmHas(url, type) {
|
|
803
|
+
return this.hasEsmCache() && (type === void 0 ? this.internal.loadCache.has(url) : this.internal.loadCache.has(url, type));
|
|
804
|
+
}
|
|
805
|
+
/** ES module cache keys, empty when this thread keeps no ES module cache. */
|
|
806
|
+
esmUrls() {
|
|
807
|
+
return this.hasEsmCache() ? [...Map.prototype.keys.call(this.internal.loadCache)] : [];
|
|
808
|
+
}
|
|
809
|
+
/** ES module cache keys and CommonJS cache filenames as URLs. */
|
|
810
|
+
*loadedUrls() {
|
|
811
|
+
yield* this.esmUrls();
|
|
812
|
+
for (const filename of Object.keys(commonJsCache())) yield pathToFileURL(filename).href;
|
|
813
|
+
}
|
|
814
|
+
/** The module job of the process entry point, when the loader cached it under the entry's real path. */
|
|
815
|
+
mainJob() {
|
|
816
|
+
const entry = process.argv[1];
|
|
817
|
+
if (entry === void 0) return void 0;
|
|
818
|
+
return this.esmJob(pathToFileURL(canonicalPath(resolve(entry))).href);
|
|
819
|
+
}
|
|
675
820
|
async *[Service.init]() {
|
|
821
|
+
const mainJob = this.mainJob();
|
|
822
|
+
let host;
|
|
823
|
+
this.hostModules = () => host ??= mainJob === void 0 ? Promise.resolve(/* @__PURE__ */ new Set()) : loadDependencies(mainJob, /* @__PURE__ */ new Set(), true);
|
|
676
824
|
yield async () => {
|
|
677
825
|
this.closing = true;
|
|
678
826
|
await this.watcher?.close();
|
|
@@ -686,6 +834,11 @@ let Hmr = (() => {
|
|
|
686
834
|
const started = Promise.withResolvers();
|
|
687
835
|
this.applicationReady = started.promise;
|
|
688
836
|
const unsubscribe = ready.onReady(() => {
|
|
837
|
+
try {
|
|
838
|
+
this.files.observe(this.profileModules(profileRoots(profile.dir)));
|
|
839
|
+
} catch (error) {
|
|
840
|
+
this.ctx.logger.warn(error);
|
|
841
|
+
}
|
|
689
842
|
started.resolve(true);
|
|
690
843
|
});
|
|
691
844
|
yield () => {
|
|
@@ -709,8 +862,9 @@ let Hmr = (() => {
|
|
|
709
862
|
}
|
|
710
863
|
})]);
|
|
711
864
|
if (inputs === lastInputs) return;
|
|
712
|
-
const
|
|
713
|
-
|
|
865
|
+
const { warnings, pending, restartReason } = await this.reconcileProfile();
|
|
866
|
+
if (restartReason !== void 0) this.ctx.logger.warn("restart required: %s", restartReason);
|
|
867
|
+
if (pending.length > 0) this.ctx.logger.warn("changed modules keep running their previous code until a restart: %o", pending);
|
|
714
868
|
lastInputs = inputs;
|
|
715
869
|
lastBundles = bundles;
|
|
716
870
|
for (const diagnostic of warnings) this.ctx.logger.warn(diagnostic);
|
|
@@ -724,7 +878,6 @@ let Hmr = (() => {
|
|
|
724
878
|
else this.ctx.logger.info("watching %o in %s", root, this.baseDir);
|
|
725
879
|
const match = picomatch(ignored);
|
|
726
880
|
const watchBaseDir = realpathSync(this.baseDir);
|
|
727
|
-
const mainJob = process.argv[1] === void 0 ? void 0 : this.internal.loadCache.get(pathToFileURL(resolve(process.argv[1])).href);
|
|
728
881
|
if (mainJob) this.externals = await loadDependencies(mainJob);
|
|
729
882
|
else this.externals = /* @__PURE__ */ new Set();
|
|
730
883
|
this.watcher = watch(root, {
|
|
@@ -752,7 +905,7 @@ let Hmr = (() => {
|
|
|
752
905
|
fullReload = true;
|
|
753
906
|
continue;
|
|
754
907
|
}
|
|
755
|
-
if (this.
|
|
908
|
+
if (this.esmHas(url) || this.esmHas(url, "json")) {
|
|
756
909
|
this.stashed.add(url);
|
|
757
910
|
continue;
|
|
758
911
|
}
|
|
@@ -805,7 +958,7 @@ let Hmr = (() => {
|
|
|
805
958
|
* @returns Linked module URLs, or an empty list for an uncached module.
|
|
806
959
|
*/
|
|
807
960
|
async getLinked(url) {
|
|
808
|
-
const job = this.
|
|
961
|
+
const job = this.esmJob(url);
|
|
809
962
|
if (!job) return [];
|
|
810
963
|
const linked = await job.linked;
|
|
811
964
|
return Array.prototype.map.call(linked, (job) => job.url);
|
|
@@ -875,7 +1028,7 @@ let Hmr = (() => {
|
|
|
875
1028
|
for (const [baseUrl, names] of nameMap) for (const [name, moduleNamespace] of names) try {
|
|
876
1029
|
const { url } = await names.resolve(name, baseUrl, moduleNamespace);
|
|
877
1030
|
if (this.declined.has(url)) continue;
|
|
878
|
-
const job = this.
|
|
1031
|
+
const job = this.esmJob(url);
|
|
879
1032
|
const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace());
|
|
880
1033
|
if (!job || !plugin) continue;
|
|
881
1034
|
pending.set(job, plugin);
|
|
@@ -902,41 +1055,7 @@ let Hmr = (() => {
|
|
|
902
1055
|
});
|
|
903
1056
|
}
|
|
904
1057
|
for (const [job, plugin] of pending) if (ReloadModules.include(reloads, plugin, job, this.ctx.registry.get(plugin))) this.accepted.add(job.url);
|
|
905
|
-
|
|
906
|
-
* Clear module caches for all accepted files before re-importing.
|
|
907
|
-
*
|
|
908
|
-
* We need to clear both:
|
|
909
|
-
* 1. ESM loadCache — managed by Node's internal ModuleLoader
|
|
910
|
-
* 2. CJS Module._cache — for CJS modules that were imported via import()
|
|
911
|
-
*
|
|
912
|
-
* In Node 24, CJS modules loaded via import() appear in both caches.
|
|
913
|
-
* If we only clear loadCache, the CJS cache may serve stale modules.
|
|
914
|
-
*
|
|
915
|
-
* We use Map.prototype methods directly on loadCache because:
|
|
916
|
-
* - In Node 22/23, loadCache is a plain Map<url, ModuleJob>
|
|
917
|
-
* - In Node 24, loadCache is a LoadCache extends Map<url, { [type]: ModuleJob }>
|
|
918
|
-
* where .delete() only sets the type slot to undefined (doesn't remove the entry)
|
|
919
|
-
* Using Map.prototype.delete ensures complete removal in both versions.
|
|
920
|
-
*/
|
|
921
|
-
const esmBackup = /* @__PURE__ */ new Map();
|
|
922
|
-
const cjsBackup = /* @__PURE__ */ new Map();
|
|
923
|
-
const require = createRequire(import.meta.url);
|
|
924
|
-
for (const filename of this.accepted) {
|
|
925
|
-
const job = Map.prototype.get.call(this.internal.loadCache, filename);
|
|
926
|
-
esmBackup.set(filename, job);
|
|
927
|
-
Map.prototype.delete.call(this.internal.loadCache, filename);
|
|
928
|
-
try {
|
|
929
|
-
const filepath = fileURLToPath(filename);
|
|
930
|
-
if (require.cache[filepath]) {
|
|
931
|
-
cjsBackup.set(filepath, require.cache[filepath]);
|
|
932
|
-
Reflect.deleteProperty(require.cache, filepath);
|
|
933
|
-
}
|
|
934
|
-
} catch {}
|
|
935
|
-
}
|
|
936
|
-
const rollback = () => {
|
|
937
|
-
for (const [filename, job] of esmBackup) Map.prototype.set.call(this.internal.loadCache, filename, job);
|
|
938
|
-
for (const [filepath, module] of cjsBackup) require.cache[filepath] = module;
|
|
939
|
-
};
|
|
1058
|
+
const rollback = this.evict(this.accepted);
|
|
940
1059
|
const generations = [...reloads].map(([previous, info]) => ({
|
|
941
1060
|
...info,
|
|
942
1061
|
previous,
|
|
@@ -1025,13 +1144,322 @@ let Hmr = (() => {
|
|
|
1025
1144
|
await this.ctx.loader.await();
|
|
1026
1145
|
for (const { replacements } of attempts) replacements.updateEntries(this.ctx.loader);
|
|
1027
1146
|
this.ctx.emit("hmr/reload", reloads);
|
|
1147
|
+
this.files.refresh(this.accepted);
|
|
1028
1148
|
this.stashed = /* @__PURE__ */ new Set();
|
|
1029
1149
|
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Remove modules from the ES module and CommonJS caches so the next import evaluates their files again.
|
|
1152
|
+
*
|
|
1153
|
+
* CommonJS modules loaded through `import()` appear in both caches. Node's `LoadCache` maps each URL to per-type
|
|
1154
|
+
* jobs, and its own `delete()` only clears one type slot, so whole entries are removed through `Map.prototype`.
|
|
1155
|
+
* @returns Restores the removed cache entries.
|
|
1156
|
+
*/
|
|
1157
|
+
evict(urls) {
|
|
1158
|
+
const esmBackup = /* @__PURE__ */ new Map();
|
|
1159
|
+
const cjsBackup = /* @__PURE__ */ new Map();
|
|
1160
|
+
const cache = commonJsCache();
|
|
1161
|
+
const list = [...urls];
|
|
1162
|
+
const cached = new Set(this.esmUrls());
|
|
1163
|
+
for (const url of list) if (cached.has(url)) {
|
|
1164
|
+
esmBackup.set(url, Map.prototype.get.call(this.internal.loadCache, url));
|
|
1165
|
+
Map.prototype.delete.call(this.internal.loadCache, url);
|
|
1166
|
+
}
|
|
1167
|
+
for (const filepath of list.filter((url) => url.startsWith("file:")).map((url) => fileURLToPath(url))) {
|
|
1168
|
+
const module = cache[filepath];
|
|
1169
|
+
if (module !== void 0) {
|
|
1170
|
+
cjsBackup.set(filepath, module);
|
|
1171
|
+
Reflect.deleteProperty(cache, filepath);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
return () => {
|
|
1175
|
+
for (const [url, job] of esmBackup) Map.prototype.set.call(this.internal.loadCache, url, job);
|
|
1176
|
+
for (const [filepath, module] of cjsBackup) cache[filepath] = module;
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
/** Loaded `file:` modules below the given profile roots. */
|
|
1180
|
+
profileModules(roots) {
|
|
1181
|
+
return [...this.loadedUrls()].filter((url) => {
|
|
1182
|
+
if (!url.startsWith("file:")) return false;
|
|
1183
|
+
const filename = fileURLToPath(url);
|
|
1184
|
+
return roots.some((root) => filename.startsWith(root));
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
/** Static import edges between loaded modules, across ES module links and CommonJS `require` children. */
|
|
1188
|
+
async moduleGraph() {
|
|
1189
|
+
const importers = /* @__PURE__ */ new Map();
|
|
1190
|
+
const add = (parent, child) => {
|
|
1191
|
+
importers.set(child, (importers.get(child) ?? /* @__PURE__ */ new Set()).add(parent));
|
|
1192
|
+
};
|
|
1193
|
+
await Promise.all([...moduleJobs(this.internal)].map(async (job) => {
|
|
1194
|
+
for (const child of await linkedJobs(job)) add(job.url, child.url);
|
|
1195
|
+
}));
|
|
1196
|
+
for (const [filename, module] of Object.entries(commonJsCache())) {
|
|
1197
|
+
/* v8 ignore next -- Node stores no undefined values in require.cache; the index type admits them */
|
|
1198
|
+
const children = module === void 0 ? void 0 : Reflect.get(module, "children");
|
|
1199
|
+
if (!Array.isArray(children)) continue;
|
|
1200
|
+
for (const child of children) add(pathToFileURL(filename).href, pathToFileURL(child.filename).href);
|
|
1201
|
+
}
|
|
1202
|
+
return importers;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Plan the replacement of changed modules without changing anything. A changed module whose importers reach the
|
|
1206
|
+
* process entry point's static graph, or a plugin that no affected Loader entry starts, stays loaded with its
|
|
1207
|
+
* importers and is pending. The other changed modules and every module importing them are evicted by
|
|
1208
|
+
* {@link stopPlanned}, which also expires the manifests from each changed file up to its root and stops the Loader
|
|
1209
|
+
* entries running affected plugins.
|
|
1210
|
+
* @param stale Changed module URLs.
|
|
1211
|
+
* @param roots Directories, with a trailing separator, that bound the expired manifests.
|
|
1212
|
+
* @returns The pending modules and the replacement of the others.
|
|
1213
|
+
*/
|
|
1214
|
+
async planReplacement(stale, roots) {
|
|
1215
|
+
const importers = await this.moduleGraph();
|
|
1216
|
+
const host = await this.hostModules();
|
|
1217
|
+
const managed = new Set(this.ctx.loader.entries());
|
|
1218
|
+
const plan = {
|
|
1219
|
+
pending: [],
|
|
1220
|
+
evicted: /* @__PURE__ */ new Set(),
|
|
1221
|
+
manifests: /* @__PURE__ */ new Set(),
|
|
1222
|
+
plugins: /* @__PURE__ */ new Set(),
|
|
1223
|
+
fibers: /* @__PURE__ */ new Set()
|
|
1224
|
+
};
|
|
1225
|
+
for (const url of stale) {
|
|
1226
|
+
const affected = new Set([url]);
|
|
1227
|
+
for (const module of affected) for (const importer of importers.get(module) ?? []) affected.add(importer);
|
|
1228
|
+
const running = [...new Set([...affected].flatMap((module) => this.pluginsOf(module)))].flatMap((plugin) => [...this.ctx.registry.get(plugin)?.fibers ?? []].map((fiber) => ({
|
|
1229
|
+
plugin,
|
|
1230
|
+
fiber
|
|
1231
|
+
})));
|
|
1232
|
+
const affectedFibers = new Set(running.map(({ fiber }) => fiber));
|
|
1233
|
+
/** The Loader entry whose own fiber runs an affected plugin and starts this fiber directly or through its ancestors. */
|
|
1234
|
+
const owner = (fiber) => {
|
|
1235
|
+
for (let current = fiber; current.uid !== 0; current = current.parent.fiber) {
|
|
1236
|
+
const entry = current.entry;
|
|
1237
|
+
if (entry?.fiber === current && managed.has(entry) && affectedFibers.has(current)) return entry;
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
const entries = running.flatMap(({ fiber }) => owner(fiber) ?? []);
|
|
1241
|
+
const reason = [...affected].some((module) => host.has(module)) ? "host-module" : entries.length < running.length ? "unmanaged-plugin" : void 0;
|
|
1242
|
+
if (reason !== void 0) {
|
|
1243
|
+
plan.pending.push({
|
|
1244
|
+
url,
|
|
1245
|
+
reason
|
|
1246
|
+
});
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
for (const manifest of packageManifests(url, roots)) plan.manifests.add(manifest);
|
|
1250
|
+
for (const module of affected) plan.evicted.add(module);
|
|
1251
|
+
for (const { plugin, fiber } of running) {
|
|
1252
|
+
plan.plugins.add(plugin);
|
|
1253
|
+
plan.fibers.add(fiber);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return plan;
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* The plugins a loaded module exports, read from the ES module namespace and the CommonJS exports Node cached for it
|
|
1260
|
+
* without evaluating anything.
|
|
1261
|
+
* @param url Loaded module URL.
|
|
1262
|
+
* @returns The exported plugins.
|
|
1263
|
+
*/
|
|
1264
|
+
pluginsOf(url) {
|
|
1265
|
+
const namespace = namespaceOf(this.esmJob(url));
|
|
1266
|
+
const required = url.startsWith("file:") ? commonJsCache()[fileURLToPath(url)] : void 0;
|
|
1267
|
+
return [namespace, required?.loaded === true ? required.exports : void 0].filter((value) => value !== void 0).map((value) => this.ctx.loader.unwrapExports(value));
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Carry out a planned replacement: expire its manifests, evict its modules without a backup, and stop its Loader
|
|
1271
|
+
* entries, so applying the generation imports the current files when it starts them.
|
|
1272
|
+
* @param plan The planned replacement.
|
|
1273
|
+
*/
|
|
1274
|
+
async stopPlanned(plan) {
|
|
1275
|
+
for (const manifest of plan.manifests) this.manifests.invalidate(manifest);
|
|
1276
|
+
this.evict(plan.evicted);
|
|
1277
|
+
for (const plugin of plan.plugins) this.ctx.registry.delete(plugin);
|
|
1278
|
+
await Promise.allSettled([...plan.fibers].map((fiber) => fiber.await()));
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Evict the loaded modules below the directories of mappings a recomposition dropped and expire their package
|
|
1282
|
+
* configuration, so a disabled or removed package that is installed or enabled again is imported afresh. Modules of
|
|
1283
|
+
* DSH's own static graph, and those below a directory that is still mapped, stay loaded.
|
|
1284
|
+
* @param dropped Real directories, with a trailing separator, of the dropped mappings.
|
|
1285
|
+
* @param kept Real directories, with a trailing separator, of the mappings that stay.
|
|
1286
|
+
*/
|
|
1287
|
+
async evictDropped(dropped, kept) {
|
|
1288
|
+
if (dropped.length === 0) return;
|
|
1289
|
+
const host = new Set(await this.hostModules());
|
|
1290
|
+
const urls = this.profileModules(dropped).filter((url) => {
|
|
1291
|
+
const filename = fileURLToPath(url);
|
|
1292
|
+
return !host.has(url) && ![...kept].some((dir) => filename.startsWith(dir));
|
|
1293
|
+
});
|
|
1294
|
+
for (const url of urls) for (const manifest of packageManifests(url, dropped)) this.manifests.invalidate(manifest);
|
|
1295
|
+
this.evict(urls);
|
|
1296
|
+
}
|
|
1297
|
+
/** Report a failed replacement step and retain the caller’s fallback outcome. */
|
|
1298
|
+
async contained(step, operation, fallback) {
|
|
1299
|
+
try {
|
|
1300
|
+
return await operation();
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
this.ctx.logger.warn("%s failed; module replacement was not completed", step);
|
|
1303
|
+
this.ctx.logger.warn(error);
|
|
1304
|
+
return fallback;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the
|
|
1309
|
+
* profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed
|
|
1310
|
+
* modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the
|
|
1311
|
+
* resolution, applies the generation, starts the stopped entries with the current files and configuration,
|
|
1312
|
+
* imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the
|
|
1313
|
+
* recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run
|
|
1314
|
+
* inside {@link runExclusive}.
|
|
1315
|
+
* @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call.
|
|
1316
|
+
* @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason.
|
|
1317
|
+
* @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a
|
|
1318
|
+
* replacement plugin that fails to load or start, whose previous code no longer runs.
|
|
1319
|
+
*/
|
|
1320
|
+
async reconcileProfile(requiredIds = []) {
|
|
1321
|
+
if (this.executing.getStore() !== true) throw new Error("HMR profile reconciliation must run inside runExclusive");
|
|
1322
|
+
const profile = this.ownerContext.get("profileContext");
|
|
1323
|
+
if (profile === void 0) throw new Error("HMR profile reconciliation requires profileContext");
|
|
1324
|
+
const running = [...this.ctx.loader.entries()].filter((entry) => entry.fiber?.state === 2);
|
|
1325
|
+
const mapped = mappedDirectories(this.ownerContext.get("pluginPackages")?.resolution);
|
|
1326
|
+
let comparison;
|
|
1327
|
+
let comparedAt = 0;
|
|
1328
|
+
let pending = [];
|
|
1329
|
+
try {
|
|
1330
|
+
const patches = readProfilePatches("dsh", profile);
|
|
1331
|
+
comparedAt = this.files.now();
|
|
1332
|
+
comparison = await this.contained("comparing loaded modules with disk", () => {
|
|
1333
|
+
const roots = profileRoots(profile.dir);
|
|
1334
|
+
return Promise.resolve({
|
|
1335
|
+
roots,
|
|
1336
|
+
...this.files.compare(this.profileModules(roots))
|
|
1337
|
+
});
|
|
1338
|
+
}, void 0);
|
|
1339
|
+
pending = [...comparison?.stale ?? []].map((url) => ({
|
|
1340
|
+
url,
|
|
1341
|
+
reason: "module-comparison"
|
|
1342
|
+
}));
|
|
1343
|
+
const compared = comparison;
|
|
1344
|
+
const unavailable = !this.hasEsmCache();
|
|
1345
|
+
if (!unavailable && compared !== void 0 && pending.length > 0) pending = await this.contained("planning module replacement", async () => {
|
|
1346
|
+
const plan = await this.planReplacement(compared.stale, compared.roots);
|
|
1347
|
+
await this.stopPlanned(plan);
|
|
1348
|
+
return plan.pending;
|
|
1349
|
+
}, pending);
|
|
1350
|
+
await this.contained("expiring failed imports", () => {
|
|
1351
|
+
if (unavailable) return;
|
|
1352
|
+
const roots = compared?.roots ?? [realpathSync(profile.dir) + sep];
|
|
1353
|
+
for (const root of roots) this.manifests.invalidate(join(root, "package.json"));
|
|
1354
|
+
this.evict(this.profileModules(roots).filter((url) => {
|
|
1355
|
+
const job = this.esmJob(url);
|
|
1356
|
+
return job !== void 0 && namespaceOf(job) === void 0;
|
|
1357
|
+
}));
|
|
1358
|
+
}, void 0);
|
|
1359
|
+
const result = await recomposeProfile(this.ownerContext.root, patches, "dsh", {
|
|
1360
|
+
requiredIds,
|
|
1361
|
+
running
|
|
1362
|
+
});
|
|
1363
|
+
const restartReason = compared === void 0 || unavailable ? "module-comparison" : result.restartReason;
|
|
1364
|
+
return {
|
|
1365
|
+
warnings: result.warnings,
|
|
1366
|
+
pending,
|
|
1367
|
+
...restartReason === void 0 ? {} : { restartReason }
|
|
1368
|
+
};
|
|
1369
|
+
} finally {
|
|
1370
|
+
const recorded = comparison;
|
|
1371
|
+
await this.contained("evicting dropped packages", () => {
|
|
1372
|
+
const kept = mappedDirectories(this.ownerContext.get("pluginPackages")?.resolution);
|
|
1373
|
+
return this.evictDropped([...mapped].filter((dir) => !kept.has(dir)), kept);
|
|
1374
|
+
}, void 0);
|
|
1375
|
+
await this.contained("recording loaded module files", () => {
|
|
1376
|
+
if (recorded === void 0) return;
|
|
1377
|
+
const urls = this.profileModules(recorded.roots);
|
|
1378
|
+
this.files.observe(urls, {
|
|
1379
|
+
seen: recorded.seen,
|
|
1380
|
+
retained: new Set(pending.map(({ url }) => url)),
|
|
1381
|
+
at: comparedAt
|
|
1382
|
+
});
|
|
1383
|
+
}, void 0);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1030
1386
|
static {
|
|
1031
1387
|
__runInitializers(_classThis, _classExtraInitializers);
|
|
1032
1388
|
}
|
|
1033
1389
|
};
|
|
1034
1390
|
return _classThis;
|
|
1035
1391
|
})();
|
|
1392
|
+
/** Node's `kErrored` module status, the same in every supported Node version. */
|
|
1393
|
+
const MODULE_ERRORED = 5;
|
|
1394
|
+
/** The namespace of a cached module job, or `undefined` when it has none or its evaluation threw. */
|
|
1395
|
+
function namespaceOf(job) {
|
|
1396
|
+
if (job?.module === void 0) return void 0;
|
|
1397
|
+
if (Reflect.apply(Reflect.get(job.module, "getStatus"), job.module, []) === MODULE_ERRORED) return void 0;
|
|
1398
|
+
try {
|
|
1399
|
+
return job.module.getNamespace();
|
|
1400
|
+
} catch (_error) {
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
/* v8 ignore stop */
|
|
1404
|
+
}
|
|
1405
|
+
/**
|
|
1406
|
+
* The jobs a module job links to. Node 24+ may expose them as an array; Node 22 keeps the job of a failed import
|
|
1407
|
+
* whose links reject, and such a job contributes no links.
|
|
1408
|
+
*/
|
|
1409
|
+
async function linkedJobs(job) {
|
|
1410
|
+
/* v8 ignore next -- CI coverage runs Node 24+, which drops failed jobs from the cache */
|
|
1411
|
+
const linked = await Promise.resolve(job.linked).catch((_error) => []);
|
|
1412
|
+
return Array.from(linked);
|
|
1413
|
+
}
|
|
1414
|
+
/** The Node-compatible CommonJS cache; the packed worker implements require without exposing this cache. */
|
|
1415
|
+
function commonJsCache() {
|
|
1416
|
+
return Reflect.get(createRequire(import.meta.url), "cache") ?? {};
|
|
1417
|
+
}
|
|
1418
|
+
/** Every module job in the loader cache; Node's `LoadCache` maps each URL to per-type jobs. */
|
|
1419
|
+
function* moduleJobs(internal) {
|
|
1420
|
+
for (const value of Map.prototype.values.call(internal.loadCache)) yield* Object.values(value).filter((job) => job !== void 0);
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* The directories of a runtime resolution's profile mappings and linked roots, in every real spelling of those that
|
|
1424
|
+
* exist, each with a trailing separator.
|
|
1425
|
+
* @param resolution The published resolution, if any.
|
|
1426
|
+
* @returns The directories; none without a resolution.
|
|
1427
|
+
*/
|
|
1428
|
+
function mappedDirectories(resolution) {
|
|
1429
|
+
const directories = /* @__PURE__ */ new Set();
|
|
1430
|
+
if (resolution === void 0) return directories;
|
|
1431
|
+
const dirs = [...resolution.entries.filter((entry) => entry.scope === "profile").map((entry) => entry.packageDir), ...resolution.linkedRoots.map((root) => root.realPath)];
|
|
1432
|
+
for (const dir of dirs) for (const spelling of existsSync(dir) ? realSpellings(dir) : [dir]) directories.add(spelling + sep);
|
|
1433
|
+
return directories;
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* The JavaScript and native real paths of an existing path. A packed worker's file system has no native realpath.
|
|
1437
|
+
* @param path Existing path.
|
|
1438
|
+
* @returns The distinct spellings.
|
|
1439
|
+
*/
|
|
1440
|
+
function realSpellings(path) {
|
|
1441
|
+
const native = Reflect.get(realpathSync, "native");
|
|
1442
|
+
return [realpathSync(path), ...typeof native === "function" ? [String(Reflect.apply(native, realpathSync, [path]))] : []];
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Real directories, with a trailing separator, of the profile and of the targets its linked packages point to. Each
|
|
1446
|
+
* appears in both real-path spellings: Node's module caches use the JavaScript one, the runtime resolution the native
|
|
1447
|
+
* one, and on Windows they differ for 8.3 short names.
|
|
1448
|
+
*/
|
|
1449
|
+
function profileRoots(dir) {
|
|
1450
|
+
const modules = join(realpathSync(dir), "node_modules");
|
|
1451
|
+
const linked = (existsSync(modules) ? readdirSync(modules) : []).flatMap((name) => name.startsWith("@") ? readdirSync(join(modules, name)).map((child) => join(modules, name, child)) : [join(modules, name)]).filter((path) => lstatSync(path).isSymbolicLink() && existsSync(path));
|
|
1452
|
+
return [...new Set([dir, ...linked].flatMap((path) => realSpellings(path)).map((root) => root + sep))];
|
|
1453
|
+
}
|
|
1454
|
+
/** Every `package.json` from a module file's directory up to the profile root or linked package target containing it. */
|
|
1455
|
+
function packageManifests(url, roots) {
|
|
1456
|
+
const manifests = [];
|
|
1457
|
+
const contains = (directory) => roots.some((root) => (directory + sep).startsWith(root));
|
|
1458
|
+
for (let directory = dirname(fileURLToPath(url)); contains(directory); directory = dirname(directory)) {
|
|
1459
|
+
const candidate = join(directory, "package.json");
|
|
1460
|
+
if (existsSync(candidate)) manifests.push(candidate);
|
|
1461
|
+
}
|
|
1462
|
+
return manifests;
|
|
1463
|
+
}
|
|
1036
1464
|
//#endregion
|
|
1037
1465
|
export { Hmr as default };
|
package/lib/types/index.d.ts
CHANGED
|
@@ -91,6 +91,9 @@ declare class Hmr extends Service {
|
|
|
91
91
|
private closing;
|
|
92
92
|
private readonly configPaths;
|
|
93
93
|
private readonly manifests;
|
|
94
|
+
private readonly files;
|
|
95
|
+
/** Every module statically reachable from the process entry point, including those under node_modules; collected on first use. */
|
|
96
|
+
private hostModules;
|
|
94
97
|
/** Serialize a caller-owned mutation with all automatic reload paths.
|
|
95
98
|
* @param operation Work that must not overlap module or configuration replacement.
|
|
96
99
|
* @returns The operation result after its asynchronous work completes.
|
|
@@ -104,6 +107,21 @@ declare class Hmr extends Service {
|
|
|
104
107
|
*/
|
|
105
108
|
watchConfig(filename: string, refresh: () => Promise<void>): Promise<() => Promise<void>>;
|
|
106
109
|
constructor(ctx: Context, config: HmrConfig);
|
|
110
|
+
/**
|
|
111
|
+
* The cached ES module job for a URL. The packed worker loader exposes no Node module cache; native Node
|
|
112
|
+
* retains this cache even when asynchronous module hooks run in a separate thread.
|
|
113
|
+
*/
|
|
114
|
+
private esmJob;
|
|
115
|
+
/** Whether this thread keeps a Node ES module cache; the packed worker loader keeps none. */
|
|
116
|
+
private hasEsmCache;
|
|
117
|
+
/** Whether the ES module cache holds a job for a URL; `false` when this thread keeps no ES module cache. */
|
|
118
|
+
private esmHas;
|
|
119
|
+
/** ES module cache keys, empty when this thread keeps no ES module cache. */
|
|
120
|
+
private esmUrls;
|
|
121
|
+
/** ES module cache keys and CommonJS cache filenames as URLs. */
|
|
122
|
+
private loadedUrls;
|
|
123
|
+
/** The module job of the process entry point, when the loader cached it under the entry's real path. */
|
|
124
|
+
private mainJob;
|
|
107
125
|
[Service.init](): AsyncGenerator<() => Promise<void>, void, unknown>;
|
|
108
126
|
/** Omit internal HMR frames from module import diagnostics.
|
|
109
127
|
* @returns The preserved outer stack frames.
|
|
@@ -123,6 +141,87 @@ declare class Hmr extends Service {
|
|
|
123
141
|
*/
|
|
124
142
|
private analyzeChanges;
|
|
125
143
|
private partialReload;
|
|
144
|
+
/**
|
|
145
|
+
* Remove modules from the ES module and CommonJS caches so the next import evaluates their files again.
|
|
146
|
+
*
|
|
147
|
+
* CommonJS modules loaded through `import()` appear in both caches. Node's `LoadCache` maps each URL to per-type
|
|
148
|
+
* jobs, and its own `delete()` only clears one type slot, so whole entries are removed through `Map.prototype`.
|
|
149
|
+
* @returns Restores the removed cache entries.
|
|
150
|
+
*/
|
|
151
|
+
private evict;
|
|
152
|
+
/** Loaded `file:` modules below the given profile roots. */
|
|
153
|
+
private profileModules;
|
|
154
|
+
/** Static import edges between loaded modules, across ES module links and CommonJS `require` children. */
|
|
155
|
+
private moduleGraph;
|
|
156
|
+
/**
|
|
157
|
+
* Plan the replacement of changed modules without changing anything. A changed module whose importers reach the
|
|
158
|
+
* process entry point's static graph, or a plugin that no affected Loader entry starts, stays loaded with its
|
|
159
|
+
* importers and is pending. The other changed modules and every module importing them are evicted by
|
|
160
|
+
* {@link stopPlanned}, which also expires the manifests from each changed file up to its root and stops the Loader
|
|
161
|
+
* entries running affected plugins.
|
|
162
|
+
* @param stale Changed module URLs.
|
|
163
|
+
* @param roots Directories, with a trailing separator, that bound the expired manifests.
|
|
164
|
+
* @returns The pending modules and the replacement of the others.
|
|
165
|
+
*/
|
|
166
|
+
private planReplacement;
|
|
167
|
+
/**
|
|
168
|
+
* The plugins a loaded module exports, read from the ES module namespace and the CommonJS exports Node cached for it
|
|
169
|
+
* without evaluating anything.
|
|
170
|
+
* @param url Loaded module URL.
|
|
171
|
+
* @returns The exported plugins.
|
|
172
|
+
*/
|
|
173
|
+
private pluginsOf;
|
|
174
|
+
/**
|
|
175
|
+
* Carry out a planned replacement: expire its manifests, evict its modules without a backup, and stop its Loader
|
|
176
|
+
* entries, so applying the generation imports the current files when it starts them.
|
|
177
|
+
* @param plan The planned replacement.
|
|
178
|
+
*/
|
|
179
|
+
private stopPlanned;
|
|
180
|
+
/**
|
|
181
|
+
* Evict the loaded modules below the directories of mappings a recomposition dropped and expire their package
|
|
182
|
+
* configuration, so a disabled or removed package that is installed or enabled again is imported afresh. Modules of
|
|
183
|
+
* DSH's own static graph, and those below a directory that is still mapped, stay loaded.
|
|
184
|
+
* @param dropped Real directories, with a trailing separator, of the dropped mappings.
|
|
185
|
+
* @param kept Real directories, with a trailing separator, of the mappings that stay.
|
|
186
|
+
*/
|
|
187
|
+
private evictDropped;
|
|
188
|
+
/** Report a failed replacement step and retain the caller’s fallback outcome. */
|
|
189
|
+
private contained;
|
|
190
|
+
/**
|
|
191
|
+
* Recompose the profile from its manifest and patch files, replacing running plugins whose loaded files below the
|
|
192
|
+
* profile directory or its linked package targets changed on disk, and publishing its runtime resolution. Changed
|
|
193
|
+
* modules are evicted and the Loader entries running them stop; app-boot's {@link recomposeProfile} then publishes the
|
|
194
|
+
* resolution, applies the generation, starts the stopped entries with the current files and configuration,
|
|
195
|
+
* imports entries whose import failed again, and judges activation. Afterwards the modules below the mappings the
|
|
196
|
+
* recomposition dropped are evicted, so a removed or deselected package is imported afresh when it returns. Callers run
|
|
197
|
+
* inside {@link runExclusive}.
|
|
198
|
+
* @param requiredIds Entry ids whose inactive state is reported as a failure even when it predates this call.
|
|
199
|
+
* @returns Inactive-entry diagnostics, the changed modules that load after a restart, and a profile-wide restart reason.
|
|
200
|
+
* @throws The patch read, publication, preparation, apply, or activation failure of the recomposition, including a
|
|
201
|
+
* replacement plugin that fails to load or start, whose previous code no longer runs.
|
|
202
|
+
*/
|
|
203
|
+
reconcileProfile(requiredIds?: readonly string[]): Promise<ProfileReconciliation>;
|
|
204
|
+
}
|
|
205
|
+
/** Outcome of {@link Hmr.reconcileProfile}. */
|
|
206
|
+
export interface ProfileReconciliation {
|
|
207
|
+
/** Diagnostics for Loader entries left inactive. */
|
|
208
|
+
warnings: string[];
|
|
209
|
+
/** Loaded modules whose files changed on disk and whose previous code keeps running until a restart. */
|
|
210
|
+
pending: PendingModule[];
|
|
211
|
+
/**
|
|
212
|
+
* Why every change waits for a restart: `module-comparison` when module caches cannot be inspected or their files
|
|
213
|
+
* compared, even when no URLs are known; `package-moved` when the runtime resolution cannot publish a moved package.
|
|
214
|
+
*/
|
|
215
|
+
restartReason?: 'module-comparison' | 'package-moved';
|
|
216
|
+
}
|
|
217
|
+
/** A changed module that loads only after a restart. */
|
|
218
|
+
export interface PendingModule {
|
|
219
|
+
url: string;
|
|
220
|
+
/**
|
|
221
|
+
* `host-module`: the process entry point's static graph imports it. `unmanaged-plugin`: it is imported by a plugin
|
|
222
|
+
* that runs outside a traversable Loader entry. `module-comparison`: loaded modules could not be compared with disk.
|
|
223
|
+
*/
|
|
224
|
+
reason: 'host-module' | 'unmanaged-plugin' | 'module-comparison';
|
|
126
225
|
}
|
|
127
226
|
declare namespace Hmr {
|
|
128
227
|
/** Cordis-compatible configuration type. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records the inode, size and modification time of loaded `file:` modules. A module is stale when its file
|
|
3
|
+
* disappeared or its identity differs from the recorded one. Change time is not recorded: creating a hard link to a
|
|
4
|
+
* file, as pnpm does when it imports from its store, changes it without changing content. A module first loaded after
|
|
5
|
+
* the last observation has no record; it is stale when its file was modified at or after that observation, less the
|
|
6
|
+
* timestamp resolution, so a reinstall between loading and the next comparison is detected at the cost of reloading a
|
|
7
|
+
* module that was first loaded after such a write.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ModuleFiles {
|
|
10
|
+
/** Identity per recorded module; `undefined` marks a stale module, such as a pending one first loaded after the last observation. */
|
|
11
|
+
private records;
|
|
12
|
+
private observedAt;
|
|
13
|
+
/**
|
|
14
|
+
* The current time of the clock the operating system stamps local file writes with, read from a new marker file that is
|
|
15
|
+
* removed again. The system clock and file timestamps can differ by a clock tick in either direction, so module files
|
|
16
|
+
* are compared with this time; when no marker can be created, the system clock is used.
|
|
17
|
+
* @returns Epoch milliseconds.
|
|
18
|
+
*/
|
|
19
|
+
now(): number;
|
|
20
|
+
/**
|
|
21
|
+
* Compare loaded file modules with disk.
|
|
22
|
+
* @param urls `file:` URLs of the loaded modules to compare.
|
|
23
|
+
* @returns URLs of stale modules, and the identity read for each compared URL (`undefined` for a missing file).
|
|
24
|
+
*/
|
|
25
|
+
compare(urls: Iterable<string>): {
|
|
26
|
+
stale: Set<string>;
|
|
27
|
+
seen: Map<string, string | undefined>;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Replace every record with the identities of the given loaded modules.
|
|
31
|
+
* @param urls `file:` URLs of the loaded modules to record.
|
|
32
|
+
* @param options `seen` supplies identities read earlier for modules whose cache entry has not changed since, so a
|
|
33
|
+
* later write stays detectable; other modules are read now, and stay stale when their file was modified after `at`.
|
|
34
|
+
* `retained` modules keep their record, or are marked stale when they have none. `at` is the observation time from
|
|
35
|
+
* {@link now} used for unrecorded modules.
|
|
36
|
+
*/
|
|
37
|
+
observe(urls: Iterable<string>, options?: {
|
|
38
|
+
seen?: ReadonlyMap<string, string | undefined>;
|
|
39
|
+
retained?: ReadonlySet<string>;
|
|
40
|
+
at?: number;
|
|
41
|
+
}): void;
|
|
42
|
+
/**
|
|
43
|
+
* Record the current identity of modules just evaluated again, leaving every other record unchanged.
|
|
44
|
+
* @param urls `file:` URLs of the re-evaluated modules.
|
|
45
|
+
*/
|
|
46
|
+
refresh(urls: Iterable<string>): void;
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=module-files.d.ts.map
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type ChokidarOptions } from 'chokidar';
|
|
2
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
3
3
|
/**
|
|
4
|
-
* Watch one patch path
|
|
4
|
+
* Watch one patch path and its parent chain without subscribing to sibling files or directories.
|
|
5
|
+
* Missing parents are observed as they appear; refresh callbacks run serially.
|
|
5
6
|
* @param ctx Context that owns watcher disposal and receives refresh failures.
|
|
6
7
|
* @param filename Absolute patch-file path.
|
|
7
8
|
* @param options Deployment watcher options; configuration watches enable write stabilization by default.
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepseek-ai/dsh-hmr",
|
|
3
3
|
"description": "Coordinated module and profile configuration hot reload",
|
|
4
|
-
"version": "0.2.1-alpha.
|
|
4
|
+
"version": "0.2.1-alpha.4",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
@@ -19,39 +19,41 @@
|
|
|
19
19
|
"default": "./lib/index.js"
|
|
20
20
|
},
|
|
21
21
|
"./src/*": "./src/*",
|
|
22
|
-
"./package.json": "./package.json"
|
|
22
|
+
"./package.json": "./package.json",
|
|
23
|
+
"./locale/*.json": "./locale/*.json"
|
|
23
24
|
},
|
|
24
25
|
"files": [
|
|
26
|
+
"locale/*.json",
|
|
25
27
|
"lib/index.js",
|
|
26
28
|
"lib/types/**/*.d.ts"
|
|
27
29
|
],
|
|
28
30
|
"author": "Shigma <shigma10826@gmail.com>",
|
|
29
31
|
"license": "MIT",
|
|
30
32
|
"peerDependencies": {
|
|
31
|
-
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.
|
|
32
|
-
"@deepseek-ai/cordis": "~4.0.5-alpha.
|
|
33
|
-
"@deepseek-ai/cordis-plugin-loader": "~1.0.6-alpha.
|
|
34
|
-
"@deepseek-ai/cordis-plugin-include": "~1.0.10-alpha.
|
|
35
|
-
"@deepseek-ai/dsh-app-boot": "0.2.1-alpha.
|
|
36
|
-
"@deepseek-ai/dsh-cmdline": "0.2.1-alpha.
|
|
33
|
+
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2",
|
|
34
|
+
"@deepseek-ai/cordis": "~4.0.5-alpha.2",
|
|
35
|
+
"@deepseek-ai/cordis-plugin-loader": "~1.0.6-alpha.2",
|
|
36
|
+
"@deepseek-ai/cordis-plugin-include": "~1.0.10-alpha.2",
|
|
37
|
+
"@deepseek-ai/dsh-app-boot": "0.2.1-alpha.4",
|
|
38
|
+
"@deepseek-ai/dsh-cmdline": "0.2.1-alpha.4"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
41
|
"@babel/code-frame": "^7.29.0",
|
|
40
|
-
"chokidar": "
|
|
42
|
+
"chokidar": "5.0.0",
|
|
41
43
|
"node-addon-require-builtin": "^0.1.9",
|
|
42
44
|
"picomatch": "^4.0.7",
|
|
43
|
-
"@deepseek-ai/schemastery": "~3.18.5-alpha.
|
|
45
|
+
"@deepseek-ai/schemastery": "~3.18.5-alpha.2"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@types/babel__code-frame": "^7.27.0",
|
|
47
49
|
"@types/picomatch": "^4.0.3",
|
|
48
50
|
"esbuild": "^0.28.2",
|
|
49
|
-
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.
|
|
50
|
-
"@deepseek-ai/cordis": "~4.0.5-alpha.
|
|
51
|
-
"@deepseek-ai/cordis-plugin-loader": "~1.0.6-alpha.
|
|
52
|
-
"@deepseek-ai/cordis-plugin-include": "~1.0.10-alpha.
|
|
53
|
-
"@deepseek-ai/dsh-app-boot": "0.2.1-alpha.
|
|
54
|
-
"@deepseek-ai/dsh-cmdline": "0.2.1-alpha.
|
|
55
|
-
"@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.
|
|
51
|
+
"@deepseek-ai/cordis-plugin-timer": "~1.1.7-alpha.2",
|
|
52
|
+
"@deepseek-ai/cordis": "~4.0.5-alpha.2",
|
|
53
|
+
"@deepseek-ai/cordis-plugin-loader": "~1.0.6-alpha.2",
|
|
54
|
+
"@deepseek-ai/cordis-plugin-include": "~1.0.10-alpha.2",
|
|
55
|
+
"@deepseek-ai/dsh-app-boot": "0.2.1-alpha.4",
|
|
56
|
+
"@deepseek-ai/dsh-cmdline": "0.2.1-alpha.4",
|
|
57
|
+
"@deepseek-ai/dsh-atomic-write": "0.2.1-alpha.4"
|
|
56
58
|
}
|
|
57
59
|
}
|