@graftland/hmr 0.2.0-beta.0 → 0.2.0-beta.2

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
@@ -16,9 +16,12 @@ npm install @graftland/hmr effect
16
16
 
17
17
  ## Watching files
18
18
 
19
- `watch` uses native filesystem events, so reloads happen quickly. It also checks
20
- the contents of loaded, versioned modules inside the watched paths once per
21
- second. This catches changes that the native backend missed. For example:
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:
22
25
 
23
26
  - the startup window of the macOS directory watcher
24
27
  - a save between an import and the watcher installation
@@ -35,8 +38,13 @@ How the checks work:
35
38
  - An unchanged failed attempt stays acknowledged until the next change. Polling
36
39
  does not retry it again and again.
37
40
  - The check respects `recursive`. It stops when the consumer closes the stream.
38
- - Its cost depends on the contents of those modules, not on the size of the
39
- directory tree.
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.
40
48
 
41
49
  `watch(paths, options)` accepts `filter(absolutePath)` in addition to
42
50
  `recursive`, `onReady`, and `debounce`. The predicate filters both native events
@@ -53,6 +61,26 @@ The CLI uses this filter for JavaScript and TypeScript module files outside
53
61
  `node_modules`. YAML configuration changes belong to the loader. They never
54
62
  trigger an HMR reload of every entry.
55
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
+
56
84
  ## Failures
57
85
 
58
86
  Debouncing does not make file writes atomic. If a reload meets incomplete code,
@@ -68,10 +96,9 @@ delivery barrier.
68
96
  ## When a restart is needed
69
97
 
70
98
  CommonJS and native dependencies need a process restart. HMR tracks dependency
71
- facts by generation for the session. This includes dynamic imports during plugin
72
- activation, after the entry import completes. Once HMR records such a
73
- dependency, later reloads of that generation return `Skipped` with
74
- `restart-required`.
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`.
75
102
 
76
103
  Loading through `require` is not isolated by ESM URL versions. This includes
77
104
  `require(esm)`. HMR is careful here. It treats a versioned module's import of
@@ -88,7 +115,7 @@ periodic status check suppresses unchanged reports. This warning does not mean
88
115
  that a reload occurred.
89
116
 
90
117
  Local file entry leases and their reachable dependency edges keep the requested
91
- path and its resolved target, together with the importing parent and generation.
118
+ path and its resolved target, together with the importing module instance.
92
119
  Reload checks this relationship before it imports, and again before it publishes
93
120
  any replacement. The same owned graph supplies polling, including dependencies
94
121
  from failed attempts awaiting retry; releasing the last lease removes its graph
@@ -104,6 +131,11 @@ They do not cover changes to package export maps or bare-package resolution.
104
131
 
105
132
  `importerScoped(specifier)` is an Effect that needs `Scope`. Each call owns a
106
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.
107
139
 
108
140
  `importerScoped(specifier, { observationScope })` separates the observation
109
141
  lease from temporary importer resources. `ModuleResolver` passes this scope as
@@ -119,12 +151,13 @@ observation generation; removing or disabling the entry closes it.
119
151
 
120
152
  How polling and restarts work with leases:
121
153
 
122
- - Polling finds its file set from each live lease. It uses the running
123
- generation and the latest uncommitted import attempt.
154
+ - Polling finds its file set from each live lease. It uses the running module
155
+ instances and the latest uncommitted import attempt.
124
156
  - It keeps failed imports and missing local dependencies, so it can retry them.
125
157
  - It stops reading an entry, and the dependencies only that entry uses, when the
126
158
  last owner closes.
127
- - A successful replacement retires the old dependency graph.
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.
128
161
  - Only dependencies that can be reached from leased entries can require a
129
162
  restart.
130
163
  - The Promise-based `importer` keeps its registrations until the HMR session
@@ -147,9 +180,22 @@ a mapping that can be represented. Validation and alias publication share the
147
180
  core replacement transaction.
148
181
 
149
182
  Native watcher producers observe their complete Exit after source cleanup and
150
- before entering background stream merging. Mixed interruption failures are
151
- logged there with the full Cause; pure interruption is silent. Ordinary backend
152
- failures still propagate to the consumer without duplicate logging.
183
+ before entering background stream merging. Only cancellation requested by the
184
+ observer with a matching cause is silent. Resource cleanup interruption and
185
+ mixed failures are logged with their full Cause. Ordinary backend failures still
186
+ propagate to the consumer without duplicate logging.
187
+
188
+ ## Long development sessions
189
+
190
+ Node does not evict versioned ESM instances. Incremental reload reduces the
191
+ number created, but a long development session still accumulates module
192
+ instances. HMR reclaims unused reachability caches and uninstantiated views.
193
+ Instantiated routing views and their source digests, dependency edges and native
194
+ capability markers remain until the session closes: arbitrary old code can
195
+ reconnect cached modules, whose static imports Node will not replay. Observation
196
+ and polling still cover only active and pending leases. This does not give
197
+ process memory a hard upper bound. Restart the process when needed; HMR does not
198
+ supervise or restart a child process.
153
199
 
154
200
  ## Cancellation
155
201
 
package/README.zh-CN.md CHANGED
@@ -16,7 +16,7 @@ npm install @graftland/hmr effect
16
16
  ## 监视文件
17
17
 
18
18
  `watch`
19
- 使用原生文件系统事件,所以重载很快。它还会每秒检查一次监视路径内已加载的、带版本的模块的内容,以捕获原生后端漏掉的变更。例如:
19
+ 先合并原生事件路径,再检查已观察文件。事件本身不触发重载,内容或本地解析必须实际变化。每秒轮询 stat 和解析,跳过元数据未变的文件读取;空闲时至少每十秒核查一次内容摘要,原生事件则强制检查内容。因此同尺寸、同时间戳且漏通知的保存仍会被发现。递归选项按观察图安装非递归目录监听,不遍历无关目录或 node_modules;实际加载的 dist 模块仍被观察,缺失路径使用最近的现存祖先目录。目录集合每秒更新。读取在会话锁外有限并发执行,确认结果时重新校验观察版本;导入基线保存实际读取字节的 SHA-256 摘要。 例如:
20
20
 
21
21
  - macOS 目录监视器的启动窗口期
22
22
  - import 与监视器安装之间发生的保存
@@ -45,6 +45,19 @@ hmr.watch([directory], {
45
45
  CLI 对 `node_modules`
46
46
  之外的 JavaScript 和 TypeScript 模块文件使用这个过滤器。YAML 配置的变更属于加载器处理,永远不会触发对所有条目的 HMR 重载。
47
47
 
48
+ ## 增量替换
49
+
50
+ 公共 `hmr.reload`
51
+ 始终强制重新导入所有已注册入口及其受跟踪依赖;没有入口时也推进导入视图,以绕过首次失败导入的缓存。
52
+
53
+ `watch`
54
+ 自动重载只失效发生变化的模块实例及其传递导入方,包括循环依赖。共享同一旧 Plugin 对象的入口一起检查;健康依赖与独立入口复用原实例。所有候选通过导入、导出身份和前后两次解析检查后,只调用一次
55
+ `Graft.replace`
56
+ 发布。失败保持运行插件不变,保留 dirty 集合及失败观察,等待下一次变化;重载期间到达的事件留给下一轮。
57
+
58
+ 模块实例保留自己的解析视图,旧代码(包括 activation 清理)晚到的 dynamic
59
+ import 不会切换到全局最新版。复用模块保留依赖边;从未执行过的动态导入无法提前发现。
60
+
48
61
  ## 失败
49
62
 
50
63
  防抖不会让文件写入变成原子操作。如果重载遇到不完整的代码,它会发出
@@ -57,8 +70,8 @@ CLI 对 `node_modules`
57
70
 
58
71
  ## 何时需要重启
59
72
 
60
- CommonJS 和原生依赖需要重启进程。HMR 按会话的 generation 跟踪依赖信息,其中包括入口 import 完成之后、插件激活期间的动态 import。一旦 HMR 记录了这样的依赖,该 generation 之后的重载都会返回
61
- `Skipped`,原因是 `restart-required`。
73
+ CommonJS 和原生依赖需要重启进程。HMR 按模块实例跟踪依赖,包括入口导入结束后的动态导入。只要存活租约能到达这类依赖,重载就返回
74
+ `Skipped / restart-required`。
62
75
 
63
76
  通过 `require` 加载的模块不受 ESM URL 版本的隔离,包括
64
77
  `require(esm)`。HMR 在这里很谨慎:带版本的模块只要 import 了 `node:module`(或
@@ -69,27 +82,27 @@ import。
69
82
 
70
83
  即使没有文件通知,监听也会报告已知的原生加载重启要求,包括启动后才获取该能力的插件。周期性状态检查不会重复报告相同状态;这条警告不表示已经执行重载。
71
84
 
72
- 本地文件入口及其可达依赖边会按代次保留请求路径、导入方和物理目标。入口和依赖共用检查逻辑及轮询数据;失败待重试的依赖图继续保留,最后一个租约释放后不再参与检查。重载在 import 之前检查这个关系,在发布任何替换之前再检查一次。如果符号链接的目标变了,重载会返回
85
+ 本地文件入口及其可达依赖边会按模块实例保留请求路径、导入方和物理目标。入口和依赖共用检查逻辑及轮询数据;失败待重试的依赖图继续保留,最后一个租约释放后不再参与检查。重载在 import 之前检查这个关系,在发布任何替换之前再检查一次。如果符号链接的目标变了,重载会返回
73
86
  `Skipped / restart-required`,并附上受影响的请求 URL,所有正在运行的插件保持不变。轮询也会检查这个关系,包括内容相同的目标和漏掉的原生事件。重新指定入口或 helper 的目标之后,请重启进程。HMR 不会使 Node 的解析缓存失效。这些检查覆盖跟踪依赖图中的本地文件请求,不覆盖包的 export
74
87
  map 或裸包解析的变化。
75
88
 
76
89
  ## 入口租约
77
90
 
78
91
  `importerScoped(specifier)` 是一个需要 `Scope`
79
- 的 Effect。每次调用都持有该入口的一个租约。最后一个租约释放后,该入口不再参与之后的重载。
92
+ 的 Effect。每次调用都持有该入口的一个租约。最后一个租约释放后,该入口不再参与之后的重载。在同一导入视图中再次取得租约会保留缓存身份;当前视图保留缓存命中所需的观察信息,但不监听无租约入口。成功提交下一视图后回收入口缓存;已实例化视图的源码摘要、依赖边和原生能力标记保留到会话关闭,因为旧代码仍可重新触达缓存实例,而 Node 不会重放其静态导入。
80
93
 
81
94
  `importerScoped(specifier, { observationScope })`
82
95
  将观察租约与候选导入的临时资源分开。`ModuleResolver`
83
96
  通过 importer 的第二个参数传递这个 scope。失败条目等待重试时应保留它,替换观察尝试或移除条目时关闭它;候选资源仍在失败时立即清理。省略该选项时使用调用方的 scope。
84
97
 
85
- 原生 watcher 在源资源清理完成后、进入后台合并边界之前观察完整 Exit。包含失败的中断会在这里记录完整 Cause,纯中断保持安静;普通后端失败继续交给消费方处理,不重复记录。
98
+ 原生 watcher 在源资源清理完成后、进入后台合并边界之前观察完整 Exit。只有观察者主动发起且来源匹配的取消保持静默。资源清理自身产生的中断和混合失败都会记录完整 Cause;普通后端失败继续交给消费方处理,不重复记录。
86
99
 
87
100
  CLI 把这个 importer 传给
88
101
  `ModuleResolver`。它的入口 scope 在配置更新、激活失败或激活等待期间保持存活,并在入口被移除时关闭。
89
102
 
90
103
  轮询和重启如何与租约配合:
91
104
 
92
- - 轮询根据每个存活的租约确定文件集合,使用正在运行的 generation 和最近一次未提交的 import 尝试。
105
+ - 轮询根据每个存活的租约确定文件集合,使用正在运行的模块实例 和最近一次未提交的 import 尝试。
93
106
  - 它会保留失败的 import 和缺失的本地依赖,以便重试。
94
107
  - 当最后一个所有者关闭时,它不再读取该入口以及仅被该入口使用的依赖。
95
108
  - 成功的替换会让旧的依赖图退役。
@@ -108,6 +121,10 @@ CLI 把这个 importer 传给
108
121
 
109
122
  在所有这些情况下,都不会替换任何东西。请重启应用,或恢复一个可以表示的映射。校验与别名发布共用核心的替换事务。
110
123
 
124
+ ## 长时间开发会话
125
+
126
+ Node 不会淘汰带版本的 ESM 实例。增量重载减少新实例数量,但长会话仍会累积模块实例。HMR 回收不再使用的可达缓存和未实例化视图;已实例化的路由视图及其源码摘要、依赖边和原生能力标记保留到会话关闭,以支持旧代码重新触达缓存模块。观察和轮询仍仅覆盖活跃及待重试租约。因此进程内存没有硬上界,需要时请重启。HMR 不提供子进程 supervisor,也不宣称彻底消除泄漏。
127
+
111
128
  ## 取消
112
129
 
113
130
  模块在取消期间失败时,HMR 会连同完整的 Cause 一起记录原始失败。它在待处理的中断掩盖已完成的 Exit 之前完成这一步。
package/README.zh-TW.md CHANGED
@@ -16,7 +16,7 @@ npm install @graftland/hmr effect
16
16
  ## 監看檔案
17
17
 
18
18
  `watch`
19
- 使用原生檔案系統事件,所以重載很快。它還會每秒檢查一次監看路徑內已載入的、帶版本的模組的內容,以捕捉原生後端漏掉的變更。例如:
19
+ 先合併原生事件路徑,再檢查已觀察檔案。事件本身不觸發重載,內容或本機解析必須實際變化。每秒輪詢 stat 和解析,略過中繼資料未變的檔案讀取;閒置時至少每十秒核查一次內容摘要,原生事件則強制檢查內容。因此同尺寸、同時間戳且漏通知的儲存仍會被發現。遞迴選項依觀察圖安裝非遞迴目錄監聽,不走訪無關目錄或 node_modules;實際載入的 dist 模組仍被觀察,缺少的路徑使用最近的現存祖先目錄。目錄集合每秒更新。讀取在工作階段鎖外有限並行執行,確認結果時重新驗證觀察版本;匯入基準保存實際讀取位元組的 SHA-256 摘要。 例如:
20
20
 
21
21
  - macOS 目錄監看器的啟動空窗期
22
22
  - import 與監看器安裝之間發生的儲存
@@ -43,6 +43,19 @@ hmr.watch([directory], {
43
43
  CLI 對 `node_modules`
44
44
  之外的 JavaScript 和 TypeScript 模組檔案使用這個過濾器。YAML 設定的變更屬於載入器處理,永遠不會觸發對所有項目的 HMR 重載。
45
45
 
46
+ ## 增量取代
47
+
48
+ 公開 `hmr.reload`
49
+ 一律強制重新匯入所有已註冊入口及其受追蹤相依模組;沒有入口時也推進匯入視圖,以繞過首次失敗匯入的快取。
50
+
51
+ `watch`
52
+ 自動重載只失效發生變化的模組實例及其傳遞匯入方,包括循環相依。共用同一舊 Plugin 物件的入口一起檢查;健康相依模組與獨立入口重用原實例。所有候選通過匯入、匯出身分及前後兩次解析檢查後,只呼叫一次
53
+ `Graft.replace`
54
+ 發布。失敗保持執行中的外掛不變,保留 dirty 集合及失敗觀察,等待下一次變化;重載期間到達的事件留給下一輪。
55
+
56
+ 模組實例保留自己的解析視圖,舊程式碼(包括 activation 清理)晚到的 dynamic
57
+ import 不會切換到全域最新版。重用模組保留相依邊;從未執行過的動態匯入無法提前發現。
58
+
46
59
  ## 失敗
47
60
 
48
61
  防抖動不會讓檔案寫入變成原子操作。如果重載遇到不完整的程式碼,它會發出
@@ -55,8 +68,8 @@ CLI 對 `node_modules`
55
68
 
56
69
  ## 何時需要重新啟動
57
70
 
58
- CommonJS 和原生相依套件需要重新啟動行程。HMR 依工作階段的 generation 追蹤相依資訊,其中包括入口 import 完成之後、外掛啟用期間的動態 import。一旦 HMR 記錄了這樣的相依套件,該 generation 之後的重載都會回傳
59
- `Skipped`,原因是 `restart-required`。
71
+ CommonJS 和原生相依模組需要重新啟動行程。HMR 依模組實例追蹤相依資訊,包括入口匯入結束後的動態匯入。只要存活租約能到達這類相依模組,重載就回傳
72
+ `Skipped / restart-required`。
60
73
 
61
74
  透過 `require` 載入的模組不受 ESM URL 版本的隔離,包括
62
75
  `require(esm)`。HMR 在這裡很謹慎:帶版本的模組只要 import 了 `node:module`(或
@@ -67,14 +80,14 @@ import。
67
80
 
68
81
  即使沒有檔案通知,監聽也會回報已知的原生載入重新啟動要求,包括啟動後才取得該能力的外掛。週期性狀態檢查不會重複回報相同狀態;這條警告不表示已經執行重載。
69
82
 
70
- 本機檔案入口及其可達相依邊會按代次保留請求路徑、匯入方和實體目標。入口和相依檔案共用檢查邏輯及輪詢資料;失敗待重試的相依圖繼續保留,最後一個租約釋放後不再參與檢查。重載在 import 之前檢查這個關係,在發布任何取代之前再檢查一次。如果符號連結的目標變了,重載會回傳
83
+ 本機檔案入口及其可達相依邊會依模組實例保留請求路徑、匯入方和實體目標。入口和相依檔案共用檢查邏輯及輪詢資料;失敗待重試的相依圖繼續保留,最後一個租約釋放後不再參與檢查。重載在 import 之前檢查這個關係,在發布任何取代之前再檢查一次。如果符號連結的目標變了,重載會回傳
71
84
  `Skipped / restart-required`,並附上受影響的請求 URL,所有正在執行的外掛保持不變。輪詢也會檢查這個關係,包括內容相同的目標和漏掉的原生事件。重新指定入口或 helper 的目標之後,請重新啟動行程。HMR 不會使 Node 的解析快取失效。這些檢查涵蓋追蹤相依圖中的本機檔案請求,不涵蓋套件的 export
72
85
  map 或裸套件解析的變化。
73
86
 
74
87
  ## 入口租約
75
88
 
76
89
  `importerScoped(specifier)` 是一個需要 `Scope`
77
- 的 Effect。每次呼叫都持有該入口的一個租約。最後一個租約釋放後,該入口不再參與之後的重載。
90
+ 的 Effect。每次呼叫都持有該入口的一個租約。最後一個租約釋放後,該入口不再參與之後的重載。在同一匯入視圖中再次取得租約會保留快取身分;目前視圖保留快取命中所需的觀察資訊,但不監聽無租約入口。成功提交下一視圖後回收入口快取;已實例化視圖的原始碼摘要、相依邊和原生能力標記保留到工作階段關閉,因為舊程式碼仍可重新觸及快取實例,而 Node 不會重播其靜態匯入。
78
91
 
79
92
  首次匯入失敗後,觀察租約仍由設定項目持有,與立即關閉的候選資源 scope 分離。修正模組後,即使原生通知遺失,也能透過輪詢復原。重試會取代觀察代次,移除或停用設定項目則關閉觀察 scope。
80
93
 
@@ -82,14 +95,14 @@ map 或裸套件解析的變化。
82
95
  將觀察租約與候選匯入的暫時資源分開。`ModuleResolver`
83
96
  透過 importer 的第二個參數傳遞這個 scope。失敗項目等待重試時應保留它,取代觀察嘗試或移除項目時關閉它;候選資源仍在失敗時立即清理。省略此選項時使用呼叫端的 scope。
84
97
 
85
- 原生 watcher 在來源資源清理完成後、進入背景合併邊界之前觀察完整 Exit。包含失敗的中斷會在這裡記錄完整 Cause,純中斷不記錄錯誤;一般後端失敗繼續交給消費端處理,不重複記錄。
98
+ 原生 watcher 在來源資源清理完成後、進入背景合併邊界之前觀察完整 Exit。只有觀察者主動發起且來源相符的取消不記錄錯誤。資源清理自身產生的中斷和混合失敗都會記錄完整 Cause;一般後端失敗繼續交給消費端處理,不重複記錄。
86
99
 
87
100
  CLI 把這個 importer 傳給
88
101
  `ModuleResolver`。它的入口 scope 在設定更新、啟用失敗或啟用等待期間保持存活,並在入口被移除時關閉。
89
102
 
90
103
  輪詢和重新啟動如何與租約配合:
91
104
 
92
- - 輪詢根據每個存活的租約決定檔案集合,使用正在執行的 generation 和最近一次未提交的 import 嘗試。
105
+ - 輪詢根據每個存活的租約決定檔案集合,使用正在執行的模組實例 和最近一次未提交的 import 嘗試。
93
106
  - 它會保留失敗的 import 和缺少的本機相依套件,以便重試。
94
107
  - 當最後一個擁有者關閉時,它不再讀取該入口以及僅被該入口使用的相依套件。
95
108
  - 成功的取代會讓舊的相依圖退役。
@@ -108,6 +121,10 @@ CLI 把這個 importer 傳給
108
121
 
109
122
  在所有這些情況下,都不會取代任何東西。請重新啟動應用程式,或還原成一個可以表示的對應。驗證與別名發布共用核心的取代交易。
110
123
 
124
+ ## 長時間開發工作階段
125
+
126
+ Node 不會淘汰帶版本的 ESM 實例。增量重載減少新實例數量,但長工作階段仍會累積模組實例。HMR 回收不再使用的可達快取和未實例化視圖;已實例化的路由視圖及其原始碼摘要、相依邊和原生能力標記保留到工作階段關閉,以支援舊程式碼重新觸及快取模組。觀察和輪詢仍僅涵蓋活躍及待重試租約。因此行程記憶體沒有硬上界,需要時請重新啟動。HMR 不提供子行程 supervisor,也不宣稱徹底消除洩漏。
127
+
111
128
  ## 取消
112
129
 
113
130
  模組在取消期間失敗時,HMR 會連同完整的 Cause 一起記錄原始失敗。它在待處理的中斷掩蓋已完成的 Exit 之前完成這一步。
package/dist/index.d.ts CHANGED
@@ -54,13 +54,15 @@ export interface ImportOptions {
54
54
  export interface Hmr {
55
55
  /** Keeps imported entries registered until this HMR session closes. */
56
56
  readonly importer: (specifier: string) => Promise<unknown>;
57
+ /** Uses the application context and Node cache, without watching or replacing the module. */
58
+ readonly importModule: (specifier: string) => Effect.Effect<unknown, HmrError>;
57
59
  /** Registers an observation lease in observationScope, or the caller's scope when omitted. */
58
60
  readonly importerScoped: (specifier: string, options?: ImportOptions) => Effect.Effect<unknown, HmrError, Scope.Scope>;
59
61
  /** Imports every entry before replacing anything. Activation failures belong to Graft. */
60
62
  readonly reload: Effect.Effect<ReloadResult, HmrError>;
61
63
  /**
62
- * Consuming this stream installs watchers and checks loaded module contents once
63
- * per second to recover missed events. Known native loading limitations emit
64
+ * Consuming this stream installs graph-driven directory watchers, polls stat once
65
+ * per second, and audits contents every ten seconds to recover missed events. Known native loading limitations emit
64
66
  * Skipped even without a file event. The consumer owns the stream's lifetimes.
65
67
  */
66
68
  readonly watch: (paths: ReadonlyArray<string>, options?: WatchOptions) => Stream.Stream<WatchResult, PlatformError.PlatformError, FileSystem.FileSystem>;
@@ -1 +1 @@
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"}
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,eAAe,sBAAsB,OAAO,gBAAgB;;WAE5D,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"}
package/dist/index.js CHANGED
@@ -44,6 +44,18 @@ const make = (options) => Effect.gen(function* () {
44
44
  });
45
45
  const serial = (effect) => effect.pipe(preserveExit, semaphore.withPermits(1));
46
46
  const hooks = yield* Effect.acquireRelease(Effect.sync(() => modules(options.base, options.include ?? ((url) => !String.includes("/node_modules/")(new URL(url).pathname)))), (hooks) => serial(Ref.set(closed, true).pipe(Effect.andThen(Effect.sync(hooks.close)))));
47
+ const cached = yield* Ref.make(HashMap.empty());
48
+ const retryDirty = yield* Ref.make(HashSet.empty());
49
+ const retryAll = yield* Ref.make(false);
50
+ const observations = Ref.get(owners).pipe(Effect.map((owners) => Array.flatMap(HashMap.toValues(owners), (owner) => Array.getSomes([owner.active, owner.pending]))));
51
+ const collect = Effect.gen(function* () {
52
+ const attempts = yield* observations;
53
+ const currentCache = yield* Ref.get(cached);
54
+ hooks.collect([...attempts, ...HashMap.toValues(currentCache)]);
55
+ const files = hooks.files(attempts);
56
+ yield* Ref.update(retryDirty, HashSet.filter((url) => HashMap.has(files, url)));
57
+ if (attempts.length === 0) yield* Ref.set(retryAll, false);
58
+ });
47
59
  const state = yield* Ref.make({
48
60
  generation: hooks.prepare(),
49
61
  entries: HashMap.empty()
@@ -75,7 +87,20 @@ const make = (options) => Effect.gen(function* () {
75
87
  }
76
88
  return Option.some(request);
77
89
  });
78
- const changedResolutions = Ref.get(owners).pipe(Effect.flatMap((owners) => Effect.forEach(hooks.resolutions(Array.flatMap(HashMap.toValues(owners), (owner) => Array.getSomes([owner.active, owner.pending]))), changedResolution)), Effect.map((changes) => Array.fromIterable(HashSet.fromIterable(Array.getSomes(changes)))));
90
+ const changedResolutions = Effect.gen(function* () {
91
+ const current = yield* Ref.get(owners);
92
+ const attempts = hooks.resolutions(Array.flatMap(HashMap.toValues(current), (owner) => Array.getSomes([owner.active, owner.pending])));
93
+ const requests = HashSet.fromIterable(Array.filterMap(attempts, (attempt) => attempt.request === void 0 ? Result.fail(void 0) : Result.succeed(attempt.request)));
94
+ const resolveAll = (requests) => Effect.forEach(requests, (request) => physicalTarget(request).pipe(Effect.map((target) => [request, target])), { concurrency: 8 }).pipe(Effect.map(HashMap.fromIterable));
95
+ const actual = yield* resolveAll(requests);
96
+ const remaining = yield* resolveAll(HashSet.fromIterable(Array.filterMap(attempts, (attempt) => attempt.request !== void 0 && Option.getOrUndefined(HashMap.get(actual, attempt.request)) === void 0 && !HashMap.has(actual, attempt.url) ? Result.succeed(attempt.url) : Result.fail(void 0))));
97
+ const targets = HashMap.union(actual, remaining);
98
+ return Array.fromIterable(HashSet.fromIterable(Array.filterMap(attempts, (attempt) => {
99
+ if (attempt.request === void 0) return Result.fail(void 0);
100
+ const actual = Option.getOrUndefined(HashMap.get(targets, attempt.request));
101
+ return actual === pathToFileURL(fileURLToPath(attempt.url)).href || actual === void 0 && Option.getOrUndefined(HashMap.get(targets, attempt.url)) === void 0 ? Result.fail(void 0) : Result.succeed(attempt.request);
102
+ })));
103
+ });
79
104
  const load = Effect.fnUntraced(function* (specifier) {
80
105
  const namespace = yield* Effect.tryPromise({
81
106
  try: () => import(
@@ -134,6 +159,7 @@ const make = (options) => Effect.gen(function* () {
134
159
  onSome: (request) => ({ request })
135
160
  })
136
161
  }));
162
+ if (Option.isSome(attempt)) yield* Ref.update(cached, HashMap.set(attempt.value.url, attempt.value));
137
163
  yield* Ref.update(owners, HashMap.set(token, {
138
164
  active: Exit.isSuccess(exit) ? attempt : Option.none(),
139
165
  pending: Exit.isFailure(exit) ? attempt : Option.none()
@@ -157,7 +183,7 @@ const make = (options) => Effect.gen(function* () {
157
183
  token,
158
184
  exit: Exit.map(exit, (entry) => entry.namespace)
159
185
  };
160
- }).pipe(Effect.ensuring(Effect.sync(hooks.discard)))), ({ token }) => serial(Effect.gen(function* () {
186
+ }).pipe(Effect.ensuring(Effect.sync(hooks.discard)), Effect.ensuring(collect))), ({ token }) => serial(Effect.gen(function* () {
161
187
  yield* Ref.update(owners, HashMap.remove(token));
162
188
  yield* Ref.update(state, (current) => ({
163
189
  ...current,
@@ -169,12 +195,30 @@ const make = (options) => Effect.gen(function* () {
169
195
  });
170
196
  })
171
197
  }));
172
- }))).pipe(Effect.flatMap(({ exit }) => exit), preserveExit);
198
+ }).pipe(Effect.ensuring(collect)))).pipe(Effect.flatMap(({ exit }) => exit), preserveExit);
199
+ const importModule = (specifier) => serial(checkOpen.pipe(Effect.andThen(Effect.tryPromise({
200
+ try: () => import(
201
+ /* @vite-ignore */
202
+ hooks.rawRequest(specifier)
203
+ ),
204
+ catch: (cause) => HmrError.make({
205
+ stage: "import",
206
+ specifier,
207
+ cause
208
+ })
209
+ }))));
173
210
  const importerScoped = (specifier, options) => options === void 0 ? importWithLease(specifier) : importWithLease(specifier).pipe(Scope.provide(options.observationScope));
174
211
  const importer = (specifier) => Ref.getUnsafe(closed) ? Promise.reject(closedError()) : run(importerScoped(specifier).pipe(Effect.provideService(Scope.Scope, imports)));
175
- const unsupportedModules = Ref.get(state).pipe(Effect.map(({ generation, entries }) => hooks.unsupported(generation, Array.fromIterable(HashMap.keys(entries)))));
176
- const reload = serial(Effect.gen(function* () {
212
+ const unsupportedAttempts = (attempts) => Array.reduce(attempts, HashSet.empty(), (all, attempt) => HashSet.union(all, hooks.unsupported(attempt.generation, [attempt.url])));
213
+ const unsupportedModules = Ref.get(owners).pipe(Effect.map((owned) => unsupportedAttempts(Array.getSomes(Array.map(HashMap.toValues(owned), (owner) => owner.active)))));
214
+ const reloadChanged = (dirty) => serial(Effect.gen(function* () {
177
215
  yield* checkOpen;
216
+ const fullRetry = yield* Ref.get(retryAll);
217
+ const force = dirty === void 0 || fullRetry;
218
+ const pendingDirty = yield* Ref.get(retryDirty);
219
+ const changedFiles = dirty === void 0 ? pendingDirty : HashSet.union(pendingDirty, dirty);
220
+ yield* Ref.set(retryDirty, changedFiles);
221
+ yield* Ref.set(retryAll, force);
178
222
  const changed = yield* changedResolutions;
179
223
  if (changed.length > 0) return WatchResult.Skipped({
180
224
  reason: "restart-required",
@@ -186,10 +230,26 @@ const make = (options) => Effect.gen(function* () {
186
230
  modules: Array.fromIterable(unsupported),
187
231
  reason: "restart-required"
188
232
  });
189
- const candidate = hooks.prepare();
233
+ const owned = yield* Ref.get(owners);
234
+ const attempts = Array.flatMap(HashMap.toValues(owned), (owner) => Array.getSomes([owner.active, owner.pending]));
235
+ const active = Array.getSomes(Array.map(HashMap.toValues(owned), (owner) => owner.active));
236
+ let selected = entries;
237
+ let invalid = HashSet.empty();
238
+ if (!force) {
239
+ let affected = changedFiles;
240
+ while (true) {
241
+ invalid = hooks.invalidated(attempts, affected);
242
+ const plugins = HashSet.fromIterable(Array.filterMap(Array.fromIterable(entries), ([url, entry]) => HashSet.has(affected, url) || Array.some(Array.fromIterable(entry.leases), (token) => Option.exists(HashMap.get(owned, token), (owner) => Array.some(Array.getSomes([owner.active, owner.pending]), (attempt) => hooks.affected(attempt, invalid)))) ? Result.succeed(entry.plugin) : Result.fail(void 0)));
243
+ selected = HashMap.filter(entries, (entry) => HashSet.has(plugins, entry.plugin));
244
+ const expanded = HashSet.union(affected, HashSet.fromIterable(HashMap.keys(selected)));
245
+ if (HashSet.size(expanded) === HashSet.size(affected)) break;
246
+ affected = expanded;
247
+ }
248
+ }
249
+ const candidate = force ? hooks.prepare() : hooks.prepare(active, invalid);
190
250
  hooks.start(candidate, true);
191
251
  return yield* Effect.gen(function* () {
192
- const loaded = yield* Effect.forEach(entries, ([url, previous]) => Ref.update(owners, HashMap.map((owner, token) => HashSet.has(previous.leases, token) ? {
252
+ const loaded = yield* Effect.forEach(selected, ([url, previous]) => Ref.update(owners, HashMap.map((owner, token) => HashSet.has(previous.leases, token) ? {
193
253
  ...owner,
194
254
  pending: Option.map(owner.active, (attempt) => ({
195
255
  ...attempt,
@@ -209,16 +269,25 @@ const make = (options) => Effect.gen(function* () {
209
269
  reason: "ambiguous-exports",
210
270
  modules: Array.map(ambiguous, ({ url }) => url)
211
271
  });
212
- const next = HashMap.fromIterable(Array.map(candidates, ({ url, plugin, leases }) => [url, {
272
+ const replacements = HashMap.fromIterable(Array.map(candidates, ({ url, plugin, leases }) => [url, {
213
273
  plugin,
214
274
  leases
215
275
  }]));
276
+ const next = HashMap.union(entries, replacements);
216
277
  const pairs = Array.map(candidates, ({ previous, plugin }) => [previous, plugin]);
217
278
  const changed = yield* changedResolutions;
218
279
  if (changed.length > 0) return WatchResult.Skipped({
219
280
  reason: "restart-required",
220
281
  modules: changed
221
282
  });
283
+ const unsafe = unsupportedAttempts(Array.map(active, (attempt) => HashMap.has(replacements, attempt.url) ? {
284
+ ...attempt,
285
+ generation: candidate
286
+ } : attempt));
287
+ if (HashSet.size(unsafe) > 0) return WatchResult.Skipped({
288
+ reason: "restart-required",
289
+ modules: Array.fromIterable(unsafe)
290
+ });
222
291
  const replacement = yield* Graft.replace(pairs).pipe(Effect.provideService(Graft.Graft, graft), Effect.result);
223
292
  if (Result.isFailure(replacement)) return WatchResult.Skipped({
224
293
  reason: "identity-conflict",
@@ -236,17 +305,24 @@ const make = (options) => Effect.gen(function* () {
236
305
  entries: next,
237
306
  generation: candidate
238
307
  });
239
- return WatchResult.Reloaded({ entries: Array.fromIterable(HashMap.keys(next)) });
308
+ yield* Ref.update(cached, (cached) => HashMap.union(HashMap.filter(cached, (_, url) => HashMap.has(next, url)), HashMap.map(replacements, (_, url) => ({
309
+ url,
310
+ generation: candidate
311
+ }))));
312
+ yield* Ref.set(retryDirty, HashSet.empty());
313
+ yield* Ref.set(retryAll, false);
314
+ return WatchResult.Reloaded({ entries: Array.map(candidates, (candidate) => candidate.url) });
240
315
  }).pipe(Effect.catchIf((error) => Schema.is(RestartRequired)(error.cause), (error) => Effect.succeed(WatchResult.Skipped({
241
316
  modules: Array.fromIterable(HashSet.add(hooks.unsupported(candidate), error.cause.url)),
242
317
  reason: "restart-required"
243
- }))), Effect.ensuring(Effect.sync(hooks.discard)));
318
+ }))), Effect.ensuring(Effect.sync(hooks.discard)), Effect.ensuring(collect));
244
319
  }));
245
320
  return {
246
321
  importer,
247
322
  importerScoped,
248
- reload,
249
- watch: (paths, watchOptions) => paths.length === 0 ? Stream.empty : changes((use) => Ref.get(owners).pipe(Effect.map((owners) => hooks.files([...Array.getSomes(Array.map(HashMap.toValues(owners), (owner) => owner.active)), ...Array.getSomes(Array.map(HashMap.toValues(owners), (owner) => owner.pending))])), Effect.flatMap(use), semaphore.withPermits(1)), paths, watchOptions).pipe(Stream.debounce(watchOptions?.debounce ?? "50 millis"), Stream.mapEffect(() => Effect.map(Effect.exit(reload), (exit) => Exit.isSuccess(exit) ? exit.value : WatchResult.Failed({ cause: exit.cause }))), Stream.merge(Stream.tick("1 second").pipe(Stream.mapEffect(() => unsupportedModules.pipe(semaphore.withPermits(1))), Stream.changes, Stream.filter((modules) => HashSet.size(modules) > 0), Stream.map((modules) => WatchResult.Skipped({
323
+ importModule,
324
+ reload: reloadChanged(),
325
+ watch: (paths, watchOptions) => paths.length === 0 ? Stream.empty : changes((use) => Ref.get(owners).pipe(Effect.map((owners) => hooks.files([...Array.getSomes(Array.map(HashMap.toValues(owners), (owner) => owner.active)), ...Array.getSomes(Array.map(HashMap.toValues(owners), (owner) => owner.pending))])), Effect.flatMap(use), semaphore.withPermits(1)), paths, watchOptions).pipe(Stream.mapEffect((dirty) => Effect.map(Effect.exit(reloadChanged(dirty)), (exit) => Exit.isSuccess(exit) ? exit.value : WatchResult.Failed({ cause: exit.cause }))), Stream.merge(Stream.tick("1 second").pipe(Stream.mapEffect(() => unsupportedModules.pipe(semaphore.withPermits(1))), Stream.changes, Stream.filter((modules) => HashSet.size(modules) > 0), Stream.map((modules) => WatchResult.Skipped({
250
326
  reason: "restart-required",
251
327
  modules: Array.fromIterable(modules)
252
328
  })))))