@graftland/hmr 0.2.0-beta.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 +58 -13
- package/README.zh-CN.md +23 -6
- package/README.zh-TW.md +23 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +71 -11
- package/dist/index.js.map +1 -1
- package/dist/internal/changes.js +68 -35
- 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 +178 -93
- package/dist/internal/modules.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +321 -161
- package/src/internal/changes.ts +188 -113
- package/src/internal/digest.ts +5 -0
- package/src/internal/modules.ts +302 -249
package/src/internal/changes.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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,
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
Effect,
|
|
12
12
|
FileSystem,
|
|
13
13
|
HashMap,
|
|
14
|
+
HashSet,
|
|
15
|
+
Equal,
|
|
16
|
+
Clock,
|
|
14
17
|
Ref,
|
|
15
18
|
Queue,
|
|
16
19
|
Option,
|
|
@@ -44,7 +47,13 @@ const observeSource = <A, E, R>(source: Stream.Stream<A, E, R>) =>
|
|
|
44
47
|
{ bufferSize: 16 },
|
|
45
48
|
)
|
|
46
49
|
|
|
47
|
-
/**
|
|
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. */
|
|
48
57
|
export const changes = (
|
|
49
58
|
withFiles: WithFiles,
|
|
50
59
|
paths: ReadonlyArray<string>,
|
|
@@ -71,129 +80,195 @@ export const changes = (
|
|
|
71
80
|
const accepted = (path: string) => options?.filter?.(path) ?? true
|
|
72
81
|
const watched = (url: string) =>
|
|
73
82
|
accepted(fileURLToPath(url)) &&
|
|
74
|
-
Array.some(roots, ({ urls }) =>
|
|
83
|
+
Array.some(roots, ({ urls, directory }) =>
|
|
75
84
|
Array.some(urls, (root) => {
|
|
76
|
-
const
|
|
85
|
+
const prefix = String.endsWith("/")(root) ? root : `${root}/`
|
|
77
86
|
return (
|
|
78
87
|
fileURLToPath(url) === fileURLToPath(root) ||
|
|
79
|
-
(
|
|
80
|
-
|
|
81
|
-
|
|
88
|
+
(directory &&
|
|
89
|
+
(options?.recursive === true
|
|
90
|
+
? String.startsWith(prefix)(url)
|
|
91
|
+
: new URL(".", url).href === prefix))
|
|
82
92
|
)
|
|
83
93
|
}),
|
|
84
94
|
)
|
|
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
|
-
),
|
|
95
|
+
const files = withFiles((loaded) =>
|
|
96
|
+
Effect.succeed(HashMap.filter(loaded, (_, url) => watched(url))),
|
|
112
97
|
)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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([
|
|
127
125
|
...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
|
-
),
|
|
126
|
+
root.directory ? root.path : dirname(root.path),
|
|
137
127
|
),
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
Stream.map(() => false),
|
|
128
|
+
...Array.map(Array.fromIterable(HashMap.keys(loaded)), (url) =>
|
|
129
|
+
dirname(fileURLToPath(url)),
|
|
141
130
|
),
|
|
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
|
-
]),
|
|
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
|
+
),
|
|
192
148
|
),
|
|
149
|
+
{ concurrency: "unbounded" },
|
|
193
150
|
),
|
|
194
151
|
),
|
|
195
|
-
Stream.
|
|
196
|
-
Stream.
|
|
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),
|
|
197
272
|
)
|
|
198
273
|
}),
|
|
199
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")
|