@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.
@@ -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 { Base64 } from "effect/encoding"
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
- /** Reconcile loaded modules because native watchers can silently miss events. */
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 directory = String.endsWith("/")(root) ? root : `${root}/`
85
+ const prefix = String.endsWith("/")(root) ? root : `${root}/`
77
86
  return (
78
87
  fileURLToPath(url) === fileURLToPath(root) ||
79
- (options?.recursive === true
80
- ? String.startsWith(directory)(url)
81
- : new URL(".", url).href === directory)
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
- 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
- ),
95
+ const files = withFiles((loaded) =>
96
+ Effect.succeed(HashMap.filter(loaded, (_, url) => watched(url))),
112
97
  )
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
- >(),
124
- )
125
- return Stream.mergeAll(
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
- 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
- ),
126
+ root.directory ? root.path : dirname(root.path),
137
127
  ),
138
- Stream.tick("1 second").pipe(
139
- Stream.drop(1),
140
- Stream.map(() => false),
128
+ ...Array.map(Array.fromIterable(HashMap.keys(loaded)), (url) =>
129
+ dirname(fileURLToPath(url)),
141
130
  ),
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
- ]),
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.filter(Boolean),
196
- Stream.map(() => undefined),
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")