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