@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 +195 -2
- package/README.zh-CN.md +137 -0
- package/README.zh-TW.md +137 -0
- package/dist/index.d.ts +39 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +250 -48
- package/dist/index.js.map +1 -1
- package/dist/internal/changes.js +92 -0
- package/dist/internal/changes.js.map +1 -0
- package/dist/internal/digest.js +6 -0
- package/dist/internal/digest.js.map +1 -0
- package/dist/internal/modules.js +237 -39
- package/dist/internal/modules.js.map +1 -1
- package/package.json +7 -5
- package/src/index.ts +632 -108
- package/src/internal/changes.ts +274 -0
- package/src/internal/digest.ts +5 -0
- package/src/internal/modules.ts +403 -57
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- watcher paths use host-platform syntax without adding a Path service requirement
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import type { FileVersion } from "./modules"
|
|
4
|
+
import type { WatchOptions } from "../index"
|
|
5
|
+
import { digest } from "./digest"
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
7
|
+
import {
|
|
8
|
+
Array,
|
|
9
|
+
Cause,
|
|
10
|
+
Exit,
|
|
11
|
+
Effect,
|
|
12
|
+
FileSystem,
|
|
13
|
+
HashMap,
|
|
14
|
+
HashSet,
|
|
15
|
+
Equal,
|
|
16
|
+
Clock,
|
|
17
|
+
Ref,
|
|
18
|
+
Queue,
|
|
19
|
+
Option,
|
|
20
|
+
Stream,
|
|
21
|
+
String,
|
|
22
|
+
} from "effect"
|
|
23
|
+
|
|
24
|
+
export type WithFiles = <A, E, R>(
|
|
25
|
+
use: (files: HashMap.HashMap<string, FileVersion>) => Effect.Effect<A, E, R>,
|
|
26
|
+
) => Effect.Effect<A, E, R>
|
|
27
|
+
|
|
28
|
+
/** Observe the producer after its resource scope closes, before merge can discard its Exit. */
|
|
29
|
+
const observeSource = <A, E, R>(source: Stream.Stream<A, E, R>) =>
|
|
30
|
+
Stream.callback<A, E, R>(
|
|
31
|
+
(queue) =>
|
|
32
|
+
source.pipe(
|
|
33
|
+
Stream.runForEachArray((values) => Queue.offerAll(queue, values)),
|
|
34
|
+
Effect.scoped,
|
|
35
|
+
Effect.onExit((exit) =>
|
|
36
|
+
Exit.isFailure(exit) &&
|
|
37
|
+
Cause.hasInterrupts(exit.cause) &&
|
|
38
|
+
!Cause.hasInterruptsOnly(exit.cause)
|
|
39
|
+
? Effect.logError(
|
|
40
|
+
"Native watcher failed during cancellation",
|
|
41
|
+
exit.cause,
|
|
42
|
+
)
|
|
43
|
+
: Effect.void,
|
|
44
|
+
),
|
|
45
|
+
Effect.andThen(Queue.end(queue)),
|
|
46
|
+
),
|
|
47
|
+
{ bufferSize: 16 },
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
/** Compare immutable observation tokens again after filesystem I/O releases the lock. */
|
|
51
|
+
const sameVersion = (a: FileVersion, b: FileVersion) =>
|
|
52
|
+
a.generation === b.generation &&
|
|
53
|
+
a.content === b.content &&
|
|
54
|
+
a.resolution === b.resolution
|
|
55
|
+
|
|
56
|
+
/** Reconcile owned modules because native watchers can silently miss events. */
|
|
57
|
+
export const changes = (
|
|
58
|
+
withFiles: WithFiles,
|
|
59
|
+
paths: ReadonlyArray<string>,
|
|
60
|
+
options?: WatchOptions,
|
|
61
|
+
) =>
|
|
62
|
+
Stream.unwrap(
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
if (paths.length === 0) return Stream.empty
|
|
65
|
+
const fs = yield* FileSystem.FileSystem
|
|
66
|
+
const roots = yield* Effect.forEach(paths, (path) =>
|
|
67
|
+
Effect.gen(function* () {
|
|
68
|
+
const canonical = yield* fs.realPath(path)
|
|
69
|
+
const info = yield* fs.stat(canonical)
|
|
70
|
+
return {
|
|
71
|
+
path: canonical,
|
|
72
|
+
urls: [
|
|
73
|
+
pathToFileURL(canonical).href,
|
|
74
|
+
pathToFileURL(resolve(path)).href,
|
|
75
|
+
],
|
|
76
|
+
directory: info.type === "Directory",
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
const accepted = (path: string) => options?.filter?.(path) ?? true
|
|
81
|
+
const watched = (url: string) =>
|
|
82
|
+
accepted(fileURLToPath(url)) &&
|
|
83
|
+
Array.some(roots, ({ urls, directory }) =>
|
|
84
|
+
Array.some(urls, (root) => {
|
|
85
|
+
const prefix = String.endsWith("/")(root) ? root : `${root}/`
|
|
86
|
+
return (
|
|
87
|
+
fileURLToPath(url) === fileURLToPath(root) ||
|
|
88
|
+
(directory &&
|
|
89
|
+
(options?.recursive === true
|
|
90
|
+
? String.startsWith(prefix)(url)
|
|
91
|
+
: new URL(".", url).href === prefix))
|
|
92
|
+
)
|
|
93
|
+
}),
|
|
94
|
+
)
|
|
95
|
+
const files = withFiles((loaded) =>
|
|
96
|
+
Effect.succeed(HashMap.filter(loaded, (_, url) => watched(url))),
|
|
97
|
+
)
|
|
98
|
+
const missing = <
|
|
99
|
+
A,
|
|
100
|
+
E extends import("effect").PlatformError.PlatformError,
|
|
101
|
+
R,
|
|
102
|
+
>(
|
|
103
|
+
effect: Effect.Effect<A, E, R>,
|
|
104
|
+
) =>
|
|
105
|
+
effect.pipe(
|
|
106
|
+
Effect.catchIf(
|
|
107
|
+
(error) => error.reason._tag === "NotFound",
|
|
108
|
+
() => Effect.undefined,
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
// Watch only graph directories, never recursively traverse an application root.
|
|
112
|
+
// Missing descendants use their nearest existing ancestor until they appear.
|
|
113
|
+
const existingDirectory = (
|
|
114
|
+
path: string,
|
|
115
|
+
): Effect.Effect<string, import("effect").PlatformError.PlatformError> =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const info = yield* missing(fs.stat(path))
|
|
118
|
+
return info?.type === "Directory" || dirname(path) === path
|
|
119
|
+
? path
|
|
120
|
+
: yield* existingDirectory(dirname(path))
|
|
121
|
+
})
|
|
122
|
+
const directories = Effect.gen(function* () {
|
|
123
|
+
const loaded = yield* files
|
|
124
|
+
const requested = HashSet.fromIterable([
|
|
125
|
+
...Array.map(roots, (root) =>
|
|
126
|
+
root.directory ? root.path : dirname(root.path),
|
|
127
|
+
),
|
|
128
|
+
...Array.map(Array.fromIterable(HashMap.keys(loaded)), (url) =>
|
|
129
|
+
dirname(fileURLToPath(url)),
|
|
130
|
+
),
|
|
131
|
+
])
|
|
132
|
+
const existing = yield* Effect.forEach(requested, existingDirectory, {
|
|
133
|
+
concurrency: 8,
|
|
134
|
+
})
|
|
135
|
+
return HashSet.fromIterable(existing)
|
|
136
|
+
})
|
|
137
|
+
const dirty = yield* Ref.make(HashSet.empty<string>())
|
|
138
|
+
const native = Stream.tick("1 second").pipe(
|
|
139
|
+
Stream.mapEffect(() => directories),
|
|
140
|
+
Stream.changesWith((a, b) => Equal.equals(a, b)),
|
|
141
|
+
Stream.switchMap((directories) =>
|
|
142
|
+
Stream.mergeAll(
|
|
143
|
+
Array.map(Array.fromIterable(directories), (directory) =>
|
|
144
|
+
fs.watch(directory, { ...options, recursive: false }).pipe(
|
|
145
|
+
observeSource,
|
|
146
|
+
Stream.map((event) => resolve(directory, event.path || ".")),
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
{ concurrency: "unbounded" },
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
Stream.tap((path) => Ref.update(dirty, HashSet.add(path))),
|
|
153
|
+
Stream.debounce(options?.debounce ?? "50 millis"),
|
|
154
|
+
Stream.mapEffect(() => Ref.getAndSet(dirty, HashSet.empty<string>())),
|
|
155
|
+
)
|
|
156
|
+
interface Observation {
|
|
157
|
+
readonly baseline: FileVersion
|
|
158
|
+
readonly content: string | undefined
|
|
159
|
+
readonly resolution: string | undefined
|
|
160
|
+
readonly stat: string
|
|
161
|
+
readonly checked: number
|
|
162
|
+
}
|
|
163
|
+
const observed = yield* Ref.make(HashMap.empty<string, Observation>())
|
|
164
|
+
const scan = (events: HashSet.HashSet<string> | undefined) =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const loaded = yield* files
|
|
167
|
+
const before = yield* Ref.get(observed)
|
|
168
|
+
const now = yield* Clock.currentTimeMillis
|
|
169
|
+
const selected = Array.filter(
|
|
170
|
+
Array.fromIterable(loaded),
|
|
171
|
+
([url]) =>
|
|
172
|
+
events === undefined ||
|
|
173
|
+
Array.some(Array.fromIterable(events), (path) => {
|
|
174
|
+
const file = fileURLToPath(url)
|
|
175
|
+
return file === path || String.startsWith(`${path}/`)(file)
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
const checked = yield* Effect.forEach(
|
|
179
|
+
selected,
|
|
180
|
+
([url, baseline]) =>
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const path = fileURLToPath(url)
|
|
183
|
+
const previous = Option.getOrUndefined(HashMap.get(before, url))
|
|
184
|
+
const info = yield* missing(fs.stat(path))
|
|
185
|
+
const stat =
|
|
186
|
+
info === undefined
|
|
187
|
+
? "missing"
|
|
188
|
+
: `${info.size}:${Option.getOrUndefined(info.mtime)?.getTime()}:${Option.getOrUndefined(info.ino)}`
|
|
189
|
+
const resolution =
|
|
190
|
+
baseline.resolution === undefined
|
|
191
|
+
? undefined
|
|
192
|
+
: yield* missing(fs.realPath(path)).pipe(
|
|
193
|
+
Effect.map((path) =>
|
|
194
|
+
path === undefined
|
|
195
|
+
? undefined
|
|
196
|
+
: pathToFileURL(path).href,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
const audit =
|
|
200
|
+
events !== undefined ||
|
|
201
|
+
previous === undefined ||
|
|
202
|
+
!sameVersion(previous.baseline, baseline) ||
|
|
203
|
+
previous.stat !== stat ||
|
|
204
|
+
now - previous.checked >= 10000
|
|
205
|
+
const content = audit
|
|
206
|
+
? yield* missing(fs.readFile(path)).pipe(
|
|
207
|
+
Effect.map((bytes) =>
|
|
208
|
+
bytes === undefined ? undefined : digest(bytes),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
: previous.content
|
|
212
|
+
const expected =
|
|
213
|
+
previous === undefined
|
|
214
|
+
? baseline
|
|
215
|
+
: {
|
|
216
|
+
content:
|
|
217
|
+
baseline.content === undefined ||
|
|
218
|
+
sameVersion(previous.baseline, baseline)
|
|
219
|
+
? previous.content
|
|
220
|
+
: baseline.content,
|
|
221
|
+
resolution: sameVersion(previous.baseline, baseline)
|
|
222
|
+
? previous.resolution
|
|
223
|
+
: baseline.resolution,
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
url,
|
|
227
|
+
observation: {
|
|
228
|
+
baseline,
|
|
229
|
+
content,
|
|
230
|
+
resolution,
|
|
231
|
+
stat,
|
|
232
|
+
checked: audit ? now : previous.checked,
|
|
233
|
+
},
|
|
234
|
+
changed:
|
|
235
|
+
expected.content !== content ||
|
|
236
|
+
expected.resolution !== resolution,
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
{ concurrency: 8 },
|
|
240
|
+
)
|
|
241
|
+
// The immutable snapshot keeps its bytes alive while owners can be released.
|
|
242
|
+
// A stale result cannot acknowledge a newer import, even when bytes coincide.
|
|
243
|
+
return yield* withFiles((current) =>
|
|
244
|
+
Ref.modify(observed, (seen) => {
|
|
245
|
+
let next = HashMap.filter(seen, (_, url) =>
|
|
246
|
+
HashMap.has(current, url),
|
|
247
|
+
)
|
|
248
|
+
let changed = HashSet.empty<string>()
|
|
249
|
+
for (const { url, observation, changed: differs } of checked) {
|
|
250
|
+
if (
|
|
251
|
+
!Option.exists(HashMap.get(current, url), (version) =>
|
|
252
|
+
sameVersion(version, observation.baseline),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
continue
|
|
256
|
+
next = HashMap.set(next, url, observation)
|
|
257
|
+
if (differs) changed = HashSet.add(changed, url)
|
|
258
|
+
}
|
|
259
|
+
return [changed, next]
|
|
260
|
+
}),
|
|
261
|
+
)
|
|
262
|
+
})
|
|
263
|
+
return Stream.merge(
|
|
264
|
+
native,
|
|
265
|
+
Stream.tick("1 second").pipe(
|
|
266
|
+
Stream.drop(1),
|
|
267
|
+
Stream.map(() => undefined),
|
|
268
|
+
),
|
|
269
|
+
).pipe(
|
|
270
|
+
Stream.mapEffect(scan),
|
|
271
|
+
Stream.filter((changed) => HashSet.size(changed) > 0),
|
|
272
|
+
)
|
|
273
|
+
}),
|
|
274
|
+
)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// @effect-diagnostics-next-line nodeBuiltinImport:off -- synchronous Node hooks need a synchronous digest of the bytes actually loaded
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
export const digest = (bytes: string | Uint8Array): string =>
|
|
5
|
+
createHash("sha256").update(bytes).digest("hex")
|