@graftland/hmr 0.1.0 → 0.2.0-beta.1

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.md CHANGED
@@ -1,13 +1,206 @@
1
1
  # @graftland/hmr
2
2
 
3
+ English |
4
+ [简体中文](https://github.com/graftland/graft/blob/main/packages/hmr/README.zh-CN.md)
5
+ |
6
+ [繁體中文](https://github.com/graftland/graft/blob/main/packages/hmr/README.zh-TW.md)
7
+
3
8
  Reloads plugin modules on Node.js without restarting the process.
4
9
 
5
10
  ```sh
6
11
  npm install @graftland/hmr effect
7
12
  ```
8
13
 
9
- `Hmr.make` gives an importer for the loader, and `reload` and `watch` to replace
10
- plugins with new code. Needs Node.js 22.15 or later.
14
+ `Hmr.make` gives you an importer for the loader. It also gives you `reload` and
15
+ `watch`, which replace plugins with new code. It needs Node.js 22.19 or later.
16
+
17
+ ## Watching files
18
+
19
+ `watch` merges native event paths before checking observed files. An event alone
20
+ does not trigger a reload: content or a local resolution must actually change.
21
+ It polls file metadata and resolutions once per second, using stat to avoid
22
+ reading unchanged files, and audits contents at least every ten seconds while
23
+ idle. Events force a content check even when stat is unchanged. These checks
24
+ recover missed events, including:
25
+
26
+ - the startup window of the macOS directory watcher
27
+ - a save between an import and the watcher installation
28
+
29
+ How the checks work:
30
+
31
+ - A newly loaded module is compared with the bytes that its module hook read. So
32
+ adding a plugin through configuration does not count as a file change.
33
+ - Each disk observation is tied to an import attempt and to the bytes that the
34
+ attempt actually read.
35
+ - A newer committed or failed attempt makes older observations invalid. So a
36
+ save between a snapshot and an import cannot leave the running plugin on the
37
+ wrong version.
38
+ - An unchanged failed attempt stays acknowledged until the next change. Polling
39
+ does not retry it again and again.
40
+ - The check respects `recursive`. It stops when the consumer closes the stream.
41
+ - Recursive watching installs nonrecursive directory watches from the observed
42
+ graph. It does not recursively enumerate unrelated directories or
43
+ node_modules. Missing paths use the nearest existing ancestor; loaded dist
44
+ modules remain observable. The directory set is reconciled once per second.
45
+ - Reads run outside the session lock with bounded concurrency. Results are
46
+ accepted only if the observation version still matches. Import baselines store
47
+ SHA-256 digests of the bytes actually loaded, not full source strings.
48
+
49
+ `watch(paths, options)` accepts `filter(absolutePath)` in addition to
50
+ `recursive`, `onReady`, and `debounce`. The predicate filters both native events
51
+ and periodic content checks. By default, every path is accepted. For example:
52
+
53
+ ```ts
54
+ hmr.watch([directory], {
55
+ recursive: true,
56
+ filter: (path) => path.endsWith(".mjs"),
57
+ })
58
+ ```
59
+
60
+ The CLI uses this filter for JavaScript and TypeScript module files outside
61
+ `node_modules`. YAML configuration changes belong to the loader. They never
62
+ trigger an HMR reload of every entry.
63
+
64
+ ## Incremental replacement
65
+
66
+ Public `hmr.reload` always forces a fresh import of every registered entry and
67
+ its tracked dependencies. It also advances the import view when there are no
68
+ entries, allowing a failed first import to be retried.
69
+
70
+ Automatic `watch` reloads invalidate changed module instances and their
71
+ transitive importers, including cycles. Entries sharing the same old Plugin
72
+ object are checked together. Clean dependencies and independent entries keep
73
+ their existing instances. All candidates pass import, export identity and
74
+ resolution checks before one `Graft.replace` publication. Failure leaves the
75
+ running plugins unchanged and retains the dirty set and failed observations for
76
+ the next change. Events arriving during a reload remain available for the next
77
+ pass.
78
+
79
+ Each module instance keeps its own resolution view. Late dynamic imports from
80
+ old code, including activation cleanup, do not switch to a global latest
81
+ version. Reused modules retain their dependency edges. Dependencies that have
82
+ never been imported cannot be discovered in advance.
83
+
84
+ ## Failures
85
+
86
+ Debouncing does not make file writes atomic. If a reload meets incomplete code,
87
+ it emits `Failed` with the original `Cause`. A later content change can start
88
+ recovery, even without another native event.
89
+
90
+ Watcher I/O failures and reconciliation I/O failures both fail the stream. So
91
+ consumers should handle both failure channels.
92
+
93
+ `onReady` reports that the watcher is installed. It is not an operating-system
94
+ delivery barrier.
95
+
96
+ ## When a restart is needed
97
+
98
+ CommonJS and native dependencies need a process restart. HMR tracks dependency
99
+ facts by module instance, including dynamic imports during plugin activation.
100
+ While a leased entry can reach such a dependency, reload returns
101
+ `Skipped / restart-required`.
102
+
103
+ Loading through `require` is not isolated by ESM URL versions. This includes
104
+ `require(esm)`. HMR is careful here. It treats a versioned module's import of
105
+ `node:module` (or `module`) as getting a native loading capability that it
106
+ cannot track. That dependency graph returns `Skipped / restart-required`, even
107
+ if the module only uses another utility from `node:module`. HMR records the
108
+ capability before use. This covers CommonJS cache hits, and it stays valid when
109
+ another entry gets the same capability. Use static or dynamic ESM imports for
110
+ dependencies that need hot reload.
111
+
112
+ Watchers report known native-loading restart requirements even without a file
113
+ notification, including when a plugin acquires the capability after startup. The
114
+ periodic status check suppresses unchanged reports. This warning does not mean
115
+ that a reload occurred.
116
+
117
+ Local file entry leases and their reachable dependency edges keep the requested
118
+ path and its resolved target, together with the importing module instance.
119
+ Reload checks this relationship before it imports, and again before it publishes
120
+ any replacement. The same owned graph supplies polling, including dependencies
121
+ from failed attempts awaiting retry; releasing the last lease removes its graph
122
+ from these checks. If a symlink target changed, reload returns
123
+ `Skipped / restart-required` with the affected request URLs. Every running
124
+ plugin stays unchanged. Polling checks this relationship too. This includes
125
+ targets with identical content and missed native events. Restart the process
126
+ after you retarget an entry or helper. HMR does not invalidate Node's resolution
127
+ caches. These checks cover local file requests in the tracked dependency graph.
128
+ They do not cover changes to package export maps or bare-package resolution.
129
+
130
+ ## Entry leases
131
+
132
+ `importerScoped(specifier)` is an Effect that needs `Scope`. Each call owns a
133
+ lease on the entry. The last release removes the entry from later reloads.
134
+ Reacquiring an entry in the same import view preserves its cached identity. The
135
+ current view retains the facts needed for those cache hits, without watching
136
+ unleased entries. A committed reload retires that entry cache. Instantiated
137
+ views retain source digests, dependency edges and native capability markers,
138
+ because old code can reconnect cached instances without replaying their imports.
139
+
140
+ `importerScoped(specifier, { observationScope })` separates the observation
141
+ lease from temporary importer resources. `ModuleResolver` passes this scope as
142
+ the importer's second argument. Keep it alive while failed entries await retry,
143
+ and close it when replacing the observation attempt or removing the entry.
144
+ Candidate resources can close immediately on failure without deleting that
145
+ observation. Omitting the option uses the caller's scope.
146
+
147
+ The CLI passes this importer to `ModuleResolver`. Each configuration entry owns
148
+ its observation scope independently of activation resources. A failed first
149
+ import remains observable after candidate cleanup. Retrying replaces the old
150
+ observation generation; removing or disabling the entry closes it.
151
+
152
+ How polling and restarts work with leases:
153
+
154
+ - Polling finds its file set from each live lease. It uses the running module
155
+ instances and the latest uncommitted import attempt.
156
+ - It keeps failed imports and missing local dependencies, so it can retry them.
157
+ - It stops reading an entry, and the dependencies only that entry uses, when the
158
+ last owner closes.
159
+ - A successful replacement stops observing files no longer reachable from any
160
+ active or pending entry. Cached instance facts survive for old routing views.
161
+ - Only dependencies that can be reached from leased entries can require a
162
+ restart.
163
+ - The Promise-based `importer` keeps its registrations until the HMR session
164
+ closes.
165
+
166
+ ## Replacement conflicts
167
+
168
+ Sometimes entries shared one Plugin object, but now they export different
169
+ objects. Then reload returns `Skipped` with reason `ambiguous-exports` and the
170
+ affected module URLs.
171
+
172
+ Two other cases return `Skipped` with reason `identity-conflict` and the
173
+ conflicting module URLs:
174
+
175
+ - Replacement identities overlap, such as `P → Q, Q → R`.
176
+ - A mapping does not match the existing runtime aliases.
177
+
178
+ In all of these cases, nothing is replaced. Restart the application, or restore
179
+ a mapping that can be represented. Validation and alias publication share the
180
+ core replacement transaction.
181
+
182
+ Native watcher producers observe their complete Exit after source cleanup and
183
+ before entering background stream merging. Mixed interruption failures are
184
+ logged there with the full Cause; pure interruption is silent. Ordinary backend
185
+ failures still propagate to the consumer without duplicate logging.
186
+
187
+ ## Long development sessions
188
+
189
+ Node does not evict versioned ESM instances. Incremental reload reduces the
190
+ number created, but a long development session still accumulates module
191
+ instances. HMR reclaims unused reachability caches and uninstantiated views.
192
+ Instantiated routing views and their source digests, dependency edges and native
193
+ capability markers remain until the session closes: arbitrary old code can
194
+ reconnect cached modules, whose static imports Node will not replay. Observation
195
+ and polling still cover only active and pending leases. This does not give
196
+ process memory a hard upper bound. Restart the process when needed; HMR does not
197
+ supervise or restart a child process.
198
+
199
+ ## Cancellation
200
+
201
+ When a module fails during cancellation, HMR records the original failure with
202
+ its full Cause. It does this before pending interruption can hide the completed
203
+ Exit.
11
204
 
12
205
  ## Documentation
13
206
 
@@ -0,0 +1,137 @@
1
+ # @graftland/hmr
2
+
3
+ [English](https://github.com/graftland/graft/blob/main/packages/hmr/README.md)
4
+ | 简体中文 |
5
+ [繁體中文](https://github.com/graftland/graft/blob/main/packages/hmr/README.zh-TW.md)
6
+
7
+ 在 Node.js 上热重载插件模块,无需重启进程。
8
+
9
+ ```sh
10
+ npm install @graftland/hmr effect
11
+ ```
12
+
13
+ `Hmr.make` 为加载器提供一个 importer,同时提供 `reload` 和
14
+ `watch`,用新代码替换插件。需要 Node.js 22.19 或更高版本。
15
+
16
+ ## 监视文件
17
+
18
+ `watch`
19
+ 先合并原生事件路径,再检查已观察文件。事件本身不触发重载,内容或本地解析必须实际变化。每秒轮询 stat 和解析,跳过元数据未变的文件读取;空闲时至少每十秒核查一次内容摘要,原生事件则强制检查内容。因此同尺寸、同时间戳且漏通知的保存仍会被发现。递归选项按观察图安装非递归目录监听,不遍历无关目录或 node_modules;实际加载的 dist 模块仍被观察,缺失路径使用最近的现存祖先目录。目录集合每秒更新。读取在会话锁外有限并发执行,确认结果时重新校验观察版本;导入基线保存实际读取字节的 SHA-256 摘要。 例如:
20
+
21
+ - macOS 目录监视器的启动窗口期
22
+ - import 与监视器安装之间发生的保存
23
+
24
+ 检查的工作方式:
25
+
26
+ - 新加载的模块会与它的模块钩子读取的字节进行比较。所以通过配置添加插件,不会被算作文件变更。
27
+ - 每次磁盘观察都与一次 import 尝试以及该尝试实际读取的字节相关联。
28
+ - 更新的已提交或已失败的尝试会使更早的观察失效。所以在快照与 import 之间发生的保存,不会让正在运行的插件停留在错误的版本上。
29
+ - 未发生变化的失败尝试会保持已确认状态,直到下一次变更。轮询不会反复重试它。
30
+ - 检查遵循 `recursive`,并在消费者关闭流时停止。
31
+ - 它的开销取决于这些模块的内容,而不是目录树的大小。
32
+
33
+ 首次导入失败后,观察租约仍由配置项持有,与立即关闭的候选资源 scope 分离。修正模块后,即使原生通知丢失,也能通过轮询恢复。重试替换观察代次,移除或禁用配置项则关闭观察 scope。
34
+
35
+ `watch(paths, options)` 除了 `recursive`、`onReady` 和 `debounce` 之外,还接受
36
+ `filter(absolutePath)`。这个谓词同时过滤原生事件和定期的内容检查。默认接受所有路径。例如:
37
+
38
+ ```ts
39
+ hmr.watch([directory], {
40
+ recursive: true,
41
+ filter: (path) => path.endsWith(".mjs"),
42
+ })
43
+ ```
44
+
45
+ CLI 对 `node_modules`
46
+ 之外的 JavaScript 和 TypeScript 模块文件使用这个过滤器。YAML 配置的变更属于加载器处理,永远不会触发对所有条目的 HMR 重载。
47
+
48
+ ## 增量替换
49
+
50
+ 公共 `hmr.reload`
51
+ 始终强制重新导入所有已注册入口及其受跟踪依赖;没有入口时也推进导入视图,以绕过首次失败导入的缓存。
52
+
53
+ `watch`
54
+ 自动重载只失效发生变化的模块实例及其传递导入方,包括循环依赖。共享同一旧 Plugin 对象的入口一起检查;健康依赖与独立入口复用原实例。所有候选通过导入、导出身份和前后两次解析检查后,只调用一次
55
+ `Graft.replace`
56
+ 发布。失败保持运行插件不变,保留 dirty 集合及失败观察,等待下一次变化;重载期间到达的事件留给下一轮。
57
+
58
+ 模块实例保留自己的解析视图,旧代码(包括 activation 清理)晚到的 dynamic
59
+ import 不会切换到全局最新版。复用模块保留依赖边;从未执行过的动态导入无法提前发现。
60
+
61
+ ## 失败
62
+
63
+ 防抖不会让文件写入变成原子操作。如果重载遇到不完整的代码,它会发出
64
+ `Failed`,并带上原始的
65
+ `Cause`。之后的内容变更可以触发恢复,即使没有新的原生事件。
66
+
67
+ 监视器 I/O 失败和对账 I/O 失败都会使流失败。所以消费者应该处理这两个失败通道。
68
+
69
+ `onReady` 表示监视器已安装。它不是操作系统层面的事件投递屏障。
70
+
71
+ ## 何时需要重启
72
+
73
+ CommonJS 和原生依赖需要重启进程。HMR 按模块实例跟踪依赖,包括入口导入结束后的动态导入。只要存活租约能到达这类依赖,重载就返回
74
+ `Skipped / restart-required`。
75
+
76
+ 通过 `require` 加载的模块不受 ESM URL 版本的隔离,包括
77
+ `require(esm)`。HMR 在这里很谨慎:带版本的模块只要 import 了 `node:module`(或
78
+ `module`),HMR 就认为它获得了一种无法跟踪的原生加载能力。该依赖图会返回
79
+ `Skipped / restart-required`,即使这个模块只用了 `node:module`
80
+ 里的其他工具。HMR 在使用之前就记录这种能力,这样可以覆盖 CommonJS 缓存命中的情况,并且在另一个入口获得同样的能力时仍然有效。需要热重载的依赖请使用静态或动态的 ESM
81
+ import。
82
+
83
+ 即使没有文件通知,监听也会报告已知的原生加载重启要求,包括启动后才获取该能力的插件。周期性状态检查不会重复报告相同状态;这条警告不表示已经执行重载。
84
+
85
+ 本地文件入口及其可达依赖边会按模块实例保留请求路径、导入方和物理目标。入口和依赖共用检查逻辑及轮询数据;失败待重试的依赖图继续保留,最后一个租约释放后不再参与检查。重载在 import 之前检查这个关系,在发布任何替换之前再检查一次。如果符号链接的目标变了,重载会返回
86
+ `Skipped / restart-required`,并附上受影响的请求 URL,所有正在运行的插件保持不变。轮询也会检查这个关系,包括内容相同的目标和漏掉的原生事件。重新指定入口或 helper 的目标之后,请重启进程。HMR 不会使 Node 的解析缓存失效。这些检查覆盖跟踪依赖图中的本地文件请求,不覆盖包的 export
87
+ map 或裸包解析的变化。
88
+
89
+ ## 入口租约
90
+
91
+ `importerScoped(specifier)` 是一个需要 `Scope`
92
+ 的 Effect。每次调用都持有该入口的一个租约。最后一个租约释放后,该入口不再参与之后的重载。在同一导入视图中再次取得租约会保留缓存身份;当前视图保留缓存命中所需的观察信息,但不监听无租约入口。成功提交下一视图后回收入口缓存;已实例化视图的源码摘要、依赖边和原生能力标记保留到会话关闭,因为旧代码仍可重新触达缓存实例,而 Node 不会重放其静态导入。
93
+
94
+ `importerScoped(specifier, { observationScope })`
95
+ 将观察租约与候选导入的临时资源分开。`ModuleResolver`
96
+ 通过 importer 的第二个参数传递这个 scope。失败条目等待重试时应保留它,替换观察尝试或移除条目时关闭它;候选资源仍在失败时立即清理。省略该选项时使用调用方的 scope。
97
+
98
+ 原生 watcher 在源资源清理完成后、进入后台合并边界之前观察完整 Exit。包含失败的中断会在这里记录完整 Cause,纯中断保持安静;普通后端失败继续交给消费方处理,不重复记录。
99
+
100
+ CLI 把这个 importer 传给
101
+ `ModuleResolver`。它的入口 scope 在配置更新、激活失败或激活等待期间保持存活,并在入口被移除时关闭。
102
+
103
+ 轮询和重启如何与租约配合:
104
+
105
+ - 轮询根据每个存活的租约确定文件集合,使用正在运行的模块实例 和最近一次未提交的 import 尝试。
106
+ - 它会保留失败的 import 和缺失的本地依赖,以便重试。
107
+ - 当最后一个所有者关闭时,它不再读取该入口以及仅被该入口使用的依赖。
108
+ - 成功的替换会让旧的依赖图退役。
109
+ - 只有从被租用的入口可以到达的依赖,才可能要求重启。
110
+ - 基于 Promise 的 `importer` 会保留它的注册,直到 HMR 会话关闭。
111
+
112
+ ## 替换冲突
113
+
114
+ 有时多个入口原本共享同一个 Plugin 对象,现在却导出了不同的对象。此时重载返回
115
+ `Skipped`,原因是 `ambiguous-exports`,并附上受影响的模块 URL。
116
+
117
+ 另外两种情况会返回 `Skipped`,原因是 `identity-conflict`,并附上冲突的模块 URL:
118
+
119
+ - 替换的标识有重叠,例如 `P → Q, Q → R`。
120
+ - 映射与现有的运行时别名不一致。
121
+
122
+ 在所有这些情况下,都不会替换任何东西。请重启应用,或恢复一个可以表示的映射。校验与别名发布共用核心的替换事务。
123
+
124
+ ## 长时间开发会话
125
+
126
+ Node 不会淘汰带版本的 ESM 实例。增量重载减少新实例数量,但长会话仍会累积模块实例。HMR 回收不再使用的可达缓存和未实例化视图;已实例化的路由视图及其源码摘要、依赖边和原生能力标记保留到会话关闭,以支持旧代码重新触达缓存模块。观察和轮询仍仅覆盖活跃及待重试租约。因此进程内存没有硬上界,需要时请重启。HMR 不提供子进程 supervisor,也不宣称彻底消除泄漏。
127
+
128
+ ## 取消
129
+
130
+ 模块在取消期间失败时,HMR 会连同完整的 Cause 一起记录原始失败。它在待处理的中断掩盖已完成的 Exit 之前完成这一步。
131
+
132
+ ## 文档
133
+
134
+ - [热重载](https://graftland.github.io/graft/zh-cn/hot-reload/)
135
+
136
+ 属于 [Graft](https://github.com/graftland/graft)。基于
137
+ [Apache 2.0](https://github.com/graftland/graft/blob/main/LICENSE) 许可。
@@ -0,0 +1,137 @@
1
+ # @graftland/hmr
2
+
3
+ [English](https://github.com/graftland/graft/blob/main/packages/hmr/README.md) |
4
+ [简体中文](https://github.com/graftland/graft/blob/main/packages/hmr/README.zh-CN.md)
5
+ | 繁體中文
6
+
7
+ 在 Node.js 上熱重載外掛模組,不需要重新啟動行程。
8
+
9
+ ```sh
10
+ npm install @graftland/hmr effect
11
+ ```
12
+
13
+ `Hmr.make` 為載入器提供一個 importer,同時提供 `reload` 和
14
+ `watch`,用新程式碼取代外掛。需要 Node.js 22.19 或更新版本。
15
+
16
+ ## 監看檔案
17
+
18
+ `watch`
19
+ 先合併原生事件路徑,再檢查已觀察檔案。事件本身不觸發重載,內容或本機解析必須實際變化。每秒輪詢 stat 和解析,略過中繼資料未變的檔案讀取;閒置時至少每十秒核查一次內容摘要,原生事件則強制檢查內容。因此同尺寸、同時間戳且漏通知的儲存仍會被發現。遞迴選項依觀察圖安裝非遞迴目錄監聽,不走訪無關目錄或 node_modules;實際載入的 dist 模組仍被觀察,缺少的路徑使用最近的現存祖先目錄。目錄集合每秒更新。讀取在工作階段鎖外有限並行執行,確認結果時重新驗證觀察版本;匯入基準保存實際讀取位元組的 SHA-256 摘要。 例如:
20
+
21
+ - macOS 目錄監看器的啟動空窗期
22
+ - import 與監看器安裝之間發生的儲存
23
+
24
+ 檢查的運作方式:
25
+
26
+ - 新載入的模組會與它的模組鉤子讀取的位元組進行比較。所以透過設定新增外掛,不會被算作檔案變更。
27
+ - 每次磁碟觀察都與一次 import 嘗試,以及該嘗試實際讀取的位元組相關聯。
28
+ - 較新的已提交或已失敗的嘗試會使較舊的觀察失效。所以在快照與 import 之間發生的儲存,不會讓正在執行的外掛停留在錯誤的版本上。
29
+ - 未發生變化的失敗嘗試會保持已確認狀態,直到下一次變更。輪詢不會反覆重試它。
30
+ - 檢查遵循 `recursive`,並在消費者關閉串流時停止。
31
+ - 它的開銷取決於這些模組的內容,而不是目錄樹的大小。
32
+
33
+ `watch(paths, options)` 除了 `recursive`、`onReady` 和 `debounce` 之外,還接受
34
+ `filter(absolutePath)`。這個謂詞同時過濾原生事件和定期的內容檢查。預設接受所有路徑。例如:
35
+
36
+ ```ts
37
+ hmr.watch([directory], {
38
+ recursive: true,
39
+ filter: (path) => path.endsWith(".mjs"),
40
+ })
41
+ ```
42
+
43
+ CLI 對 `node_modules`
44
+ 之外的 JavaScript 和 TypeScript 模組檔案使用這個過濾器。YAML 設定的變更屬於載入器處理,永遠不會觸發對所有項目的 HMR 重載。
45
+
46
+ ## 增量取代
47
+
48
+ 公開 `hmr.reload`
49
+ 一律強制重新匯入所有已註冊入口及其受追蹤相依模組;沒有入口時也推進匯入視圖,以繞過首次失敗匯入的快取。
50
+
51
+ `watch`
52
+ 自動重載只失效發生變化的模組實例及其傳遞匯入方,包括循環相依。共用同一舊 Plugin 物件的入口一起檢查;健康相依模組與獨立入口重用原實例。所有候選通過匯入、匯出身分及前後兩次解析檢查後,只呼叫一次
53
+ `Graft.replace`
54
+ 發布。失敗保持執行中的外掛不變,保留 dirty 集合及失敗觀察,等待下一次變化;重載期間到達的事件留給下一輪。
55
+
56
+ 模組實例保留自己的解析視圖,舊程式碼(包括 activation 清理)晚到的 dynamic
57
+ import 不會切換到全域最新版。重用模組保留相依邊;從未執行過的動態匯入無法提前發現。
58
+
59
+ ## 失敗
60
+
61
+ 防抖動不會讓檔案寫入變成原子操作。如果重載遇到不完整的程式碼,它會發出
62
+ `Failed`,並帶上原始的
63
+ `Cause`。之後的內容變更可以觸發復原,即使沒有新的原生事件。
64
+
65
+ 監看器 I/O 失敗和對帳 I/O 失敗都會使串流失敗。所以消費者應該處理這兩個失敗通道。
66
+
67
+ `onReady` 表示監看器已安裝。它不是作業系統層級的事件投遞屏障。
68
+
69
+ ## 何時需要重新啟動
70
+
71
+ CommonJS 和原生相依模組需要重新啟動行程。HMR 依模組實例追蹤相依資訊,包括入口匯入結束後的動態匯入。只要存活租約能到達這類相依模組,重載就回傳
72
+ `Skipped / restart-required`。
73
+
74
+ 透過 `require` 載入的模組不受 ESM URL 版本的隔離,包括
75
+ `require(esm)`。HMR 在這裡很謹慎:帶版本的模組只要 import 了 `node:module`(或
76
+ `module`),HMR 就認為它取得了一種無法追蹤的原生載入能力。該相依圖會回傳
77
+ `Skipped / restart-required`,即使這個模組只用了 `node:module`
78
+ 裡的其他工具。HMR 在使用之前就記錄這種能力,這樣可以涵蓋 CommonJS 快取命中的情況,並且在另一個入口取得同樣的能力時仍然有效。需要熱重載的相依套件請使用靜態或動態的 ESM
79
+ import。
80
+
81
+ 即使沒有檔案通知,監聽也會回報已知的原生載入重新啟動要求,包括啟動後才取得該能力的外掛。週期性狀態檢查不會重複回報相同狀態;這條警告不表示已經執行重載。
82
+
83
+ 本機檔案入口及其可達相依邊會依模組實例保留請求路徑、匯入方和實體目標。入口和相依檔案共用檢查邏輯及輪詢資料;失敗待重試的相依圖繼續保留,最後一個租約釋放後不再參與檢查。重載在 import 之前檢查這個關係,在發布任何取代之前再檢查一次。如果符號連結的目標變了,重載會回傳
84
+ `Skipped / restart-required`,並附上受影響的請求 URL,所有正在執行的外掛保持不變。輪詢也會檢查這個關係,包括內容相同的目標和漏掉的原生事件。重新指定入口或 helper 的目標之後,請重新啟動行程。HMR 不會使 Node 的解析快取失效。這些檢查涵蓋追蹤相依圖中的本機檔案請求,不涵蓋套件的 export
85
+ map 或裸套件解析的變化。
86
+
87
+ ## 入口租約
88
+
89
+ `importerScoped(specifier)` 是一個需要 `Scope`
90
+ 的 Effect。每次呼叫都持有該入口的一個租約。最後一個租約釋放後,該入口不再參與之後的重載。在同一匯入視圖中再次取得租約會保留快取身分;目前視圖保留快取命中所需的觀察資訊,但不監聽無租約入口。成功提交下一視圖後回收入口快取;已實例化視圖的原始碼摘要、相依邊和原生能力標記保留到工作階段關閉,因為舊程式碼仍可重新觸及快取實例,而 Node 不會重播其靜態匯入。
91
+
92
+ 首次匯入失敗後,觀察租約仍由設定項目持有,與立即關閉的候選資源 scope 分離。修正模組後,即使原生通知遺失,也能透過輪詢復原。重試會取代觀察代次,移除或停用設定項目則關閉觀察 scope。
93
+
94
+ `importerScoped(specifier, { observationScope })`
95
+ 將觀察租約與候選匯入的暫時資源分開。`ModuleResolver`
96
+ 透過 importer 的第二個參數傳遞這個 scope。失敗項目等待重試時應保留它,取代觀察嘗試或移除項目時關閉它;候選資源仍在失敗時立即清理。省略此選項時使用呼叫端的 scope。
97
+
98
+ 原生 watcher 在來源資源清理完成後、進入背景合併邊界之前觀察完整 Exit。包含失敗的中斷會在這裡記錄完整 Cause,純中斷不記錄錯誤;一般後端失敗繼續交給消費端處理,不重複記錄。
99
+
100
+ CLI 把這個 importer 傳給
101
+ `ModuleResolver`。它的入口 scope 在設定更新、啟用失敗或啟用等待期間保持存活,並在入口被移除時關閉。
102
+
103
+ 輪詢和重新啟動如何與租約配合:
104
+
105
+ - 輪詢根據每個存活的租約決定檔案集合,使用正在執行的模組實例 和最近一次未提交的 import 嘗試。
106
+ - 它會保留失敗的 import 和缺少的本機相依套件,以便重試。
107
+ - 當最後一個擁有者關閉時,它不再讀取該入口以及僅被該入口使用的相依套件。
108
+ - 成功的取代會讓舊的相依圖退役。
109
+ - 只有從被租用的入口可以到達的相依套件,才可能要求重新啟動。
110
+ - 以 Promise 為基礎的 `importer` 會保留它的註冊,直到 HMR 工作階段關閉。
111
+
112
+ ## 取代衝突
113
+
114
+ 有時多個入口原本共用同一個 Plugin 物件,現在卻匯出了不同的物件。此時重載回傳
115
+ `Skipped`,原因是 `ambiguous-exports`,並附上受影響的模組 URL。
116
+
117
+ 另外兩種情況會回傳 `Skipped`,原因是 `identity-conflict`,並附上衝突的模組 URL:
118
+
119
+ - 取代的識別有重疊,例如 `P → Q, Q → R`。
120
+ - 對應與現有的執行期別名不一致。
121
+
122
+ 在所有這些情況下,都不會取代任何東西。請重新啟動應用程式,或還原成一個可以表示的對應。驗證與別名發布共用核心的取代交易。
123
+
124
+ ## 長時間開發工作階段
125
+
126
+ Node 不會淘汰帶版本的 ESM 實例。增量重載減少新實例數量,但長工作階段仍會累積模組實例。HMR 回收不再使用的可達快取和未實例化視圖;已實例化的路由視圖及其原始碼摘要、相依邊和原生能力標記保留到工作階段關閉,以支援舊程式碼重新觸及快取模組。觀察和輪詢仍僅涵蓋活躍及待重試租約。因此行程記憶體沒有硬上界,需要時請重新啟動。HMR 不提供子行程 supervisor,也不宣稱徹底消除洩漏。
127
+
128
+ ## 取消
129
+
130
+ 模組在取消期間失敗時,HMR 會連同完整的 Cause 一起記錄原始失敗。它在待處理的中斷掩蓋已完成的 Exit 之前完成這一步。
131
+
132
+ ## 文件
133
+
134
+ - [熱重載](https://graftland.github.io/graft/zh-tw/hot-reload/)
135
+
136
+ 屬於 [Graft](https://github.com/graftland/graft)。依
137
+ [Apache 2.0](https://github.com/graftland/graft/blob/main/LICENSE) 授權。
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Graft } from "@graftland/graft";
2
- import { Cause, Duration, Effect, FileSystem, PlatformError, Schema, Scope, Stream } from "effect";
2
+ import { Cause, Data, Duration, Effect, FileSystem, PlatformError, Schema, Scope, Stream } from "effect";
3
3
  declare const HmrError_base: Schema.Class<HmrError, Schema.TaggedStruct<"HmrError", {
4
4
  readonly stage: Schema.Literals<readonly ["import", "exports", "closed"]>;
5
5
  readonly specifier: Schema.String;
@@ -10,33 +10,60 @@ declare const HmrError_base: Schema.Class<HmrError, Schema.TaggedStruct<"HmrErro
10
10
  export declare class HmrError extends HmrError_base {
11
11
  get message(): string;
12
12
  }
13
- export type ReloadResult = {
13
+ export type WatchResult = Data.TaggedEnum<{
14
+ Reloaded: {
15
+ readonly entries: ReadonlyArray<string>;
16
+ };
17
+ Skipped: {
18
+ readonly modules: ReadonlyArray<string>;
19
+ readonly reason: "restart-required" | "ambiguous-exports" | "identity-conflict";
20
+ };
21
+ Failed: {
22
+ readonly cause: Cause.Cause<HmrError>;
23
+ };
24
+ }>;
25
+ export declare const WatchResult: Data.TaggedEnum.Constructor<{
26
+ readonly _tag: "Failed";
27
+ readonly cause: Cause.Cause<HmrError>;
28
+ } | {
14
29
  readonly _tag: "Reloaded";
15
30
  readonly entries: ReadonlyArray<string>;
16
31
  } | {
17
32
  readonly _tag: "Skipped";
18
33
  readonly modules: ReadonlyArray<string>;
19
- readonly reason: "restart-required";
20
- };
21
- export type WatchResult = ReloadResult | {
34
+ readonly reason: "restart-required" | "ambiguous-exports" | "identity-conflict";
35
+ }>;
36
+ export type ReloadResult = Exclude<WatchResult, {
22
37
  readonly _tag: "Failed";
23
- readonly cause: Cause.Cause<HmrError>;
24
- };
38
+ }>;
25
39
  export interface Options {
26
40
  /** Application module URL, usually import.meta.url; used for bare and relative resolution. */
27
41
  readonly base: string;
28
42
  /** Files to version. Defaults to file URLs outside node_modules. Entries are always included. */
29
43
  readonly include?: (url: string) => boolean;
30
44
  }
45
+ export interface WatchOptions extends FileSystem.WatchOptions {
46
+ readonly debounce?: Duration.Input;
47
+ /** Receives an absolute file path; applies to native events and reconciliation. */
48
+ readonly filter?: (path: string) => boolean;
49
+ }
50
+ /** Separates retry observations from an importer's temporary resource scope. */
51
+ export interface ImportOptions {
52
+ readonly observationScope: Scope.Scope;
53
+ }
31
54
  export interface Hmr {
32
- /** Pass directly to ModuleResolver.layer({ base, importer }). */
55
+ /** Keeps imported entries registered until this HMR session closes. */
33
56
  readonly importer: (specifier: string) => Promise<unknown>;
57
+ /** Registers an observation lease in observationScope, or the caller's scope when omitted. */
58
+ readonly importerScoped: (specifier: string, options?: ImportOptions) => Effect.Effect<unknown, HmrError, Scope.Scope>;
34
59
  /** Imports every entry before replacing anything. Activation failures belong to Graft. */
35
60
  readonly reload: Effect.Effect<ReloadResult, HmrError>;
36
- /** Consuming this stream installs the watchers; the consumer owns their lifetime. */
37
- readonly watch: (paths: ReadonlyArray<string>, options?: FileSystem.WatchOptions & {
38
- readonly debounce?: Duration.Input;
39
- }) => Stream.Stream<WatchResult, PlatformError.PlatformError, FileSystem.FileSystem>;
61
+ /**
62
+ * Consuming this stream installs graph-driven directory watchers, polls stat once
63
+ * per second, and audits contents every ten seconds to recover missed events. Known native loading limitations emit
64
+ * Skipped even without a file event. The consumer owns the stream's lifetimes.
65
+ */
66
+ readonly watch: (paths: ReadonlyArray<string>, options?: WatchOptions) => Stream.Stream<WatchResult, PlatformError.PlatformError, FileSystem.FileSystem>;
40
67
  }
41
68
  /**
42
69
  * Installs Node module hooks until the enclosing scope closes. Module evaluation
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;WAwBmB;;qBAFN,iBAAiB;MAQhB;;YAKF;WACE;WAA2B,SAAS;;WAEtC;WACA,SAAS;WACT;;YAGA,cACT;WAES;WACA,OAAO,MAAM,MAAM;;iBAGd;;WAEP;;WAEA,WAAW;;iBAGJ;;WAEP,WAAW,sBAAsB;;WAEjC,QAAQ,OAAO,OAAO,cAAc;;WAEpC,QACR,OAAO,uBACP,UAAU,WAAW;aAA0B,WAAW,SAAS;QAC/D,OAAO,OACX,aACA,cAAc,eACd,WAAW;;;;;;qBAQA,OAAI,SACP,YACP,OAAO,OAAO,YAAY,MAAM,QAAQ,MAAM"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;;;WAsCmB;;qBAFN,iBAAiB;MAQhB;;YAKF,cAAc,KAAK;EAC9B;aAAqB,SAAS;;EAC9B;aACU,SAAS;aACT;;EAGV;aAAmB,OAAO,MAAM,MAAM;;;qBAG1B,aAAW,KAAA,WAAA;;WAHG,OAAA,MAAM,MAAM;;;WANR,SAAA;;;WAEX,SAAA;WAEjB;;YAOS,eAAe,QAAQ;WAAwB;;iBAE1C;;WAEP;;WAEA,WAAW;;iBAGJ,qBAAqB,WAAW;WACvC,WAAW,SAAS;;WAEpB,UAAU;;;iBAIH;WACP,kBAAkB,MAAM;;iBAGjB;;WAEP,WAAW,sBAAsB;;WAEjC,iBACR,mBACA,UAAU,kBACN,OAAO,gBAAgB,UAAU,MAAM;;WAEnC,QAAQ,OAAO,OAAO,cAAc;;;;;;WAMpC,QACR,OAAO,uBACP,UAAU,iBACN,OAAO,OACX,aACA,cAAc,eACd,WAAW;;;;;;qBAQA,OAAI,SACP,YACP,OAAO,OAAO,YAAY,MAAM,QAAQ,MAAM"}