@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.
@@ -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")