@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.
@@ -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 { Base64 } from "effect/encoding"
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
- source.pipe(
30
- Stream.runForEachArray((values) => Queue.offerAll(queue, values)),
31
- Effect.scoped,
32
- Effect.onExit((exit) =>
33
- Exit.isFailure(exit) &&
34
- Cause.hasInterrupts(exit.cause) &&
35
- !Cause.hasInterruptsOnly(exit.cause)
36
- ? Effect.logError(
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
- : Effect.void,
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
- /** Reconcile loaded modules because native watchers can silently miss events. */
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 directory = String.endsWith("/")(root) ? root : `${root}/`
112
+ const prefix = String.endsWith("/")(root) ? root : `${root}/`
77
113
  return (
78
114
  fileURLToPath(url) === fileURLToPath(root) ||
79
- (options?.recursive === true
80
- ? String.startsWith(directory)(url)
81
- : new URL(".", url).href === directory)
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
- const snapshot = withFiles((loaded) =>
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
- return Stream.mergeAll(
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
- fs.watch(root.path, options).pipe(
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
- Stream.tick("1 second").pipe(
139
- Stream.drop(1),
140
- Stream.map(() => false),
155
+ ...Array.map(Array.fromIterable(HashMap.keys(loaded)), (url) =>
156
+ dirname(fileURLToPath(url)),
141
157
  ),
142
- ],
143
- { concurrency: "unbounded" },
144
- ).pipe(
145
- // One consumer serializes snapshots from events and polling. A native
146
- // event also advances the baseline, avoiding a second reload on the next tick.
147
- Stream.mapEffect((event) =>
148
- snapshot.pipe(
149
- Effect.flatMap((current) =>
150
- Ref.modify(observed, (before) => [
151
- event ||
152
- Array.some(
153
- current,
154
- ({ url, baseline, content, resolution }) => {
155
- const expected = Option.match(HashMap.get(before, url), {
156
- onNone: () => ({
157
- content: baseline.content,
158
- resolution: baseline.resolution,
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.filter(Boolean),
196
- Stream.map(() => undefined),
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")