@graftland/hmr 0.1.0 → 0.2.0-beta.0

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,199 @@
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"
3
+ import type { FileVersion } from "./modules"
4
+ import type { WatchOptions } from "../index"
5
+ import { Base64 } from "effect/encoding"
6
+ import { fileURLToPath, pathToFileURL } from "node:url"
7
+ import {
8
+ Array,
9
+ Cause,
10
+ Exit,
11
+ Effect,
12
+ FileSystem,
13
+ HashMap,
14
+ Ref,
15
+ Queue,
16
+ Option,
17
+ Stream,
18
+ String,
19
+ } from "effect"
20
+
21
+ export type WithFiles = <A, E, R>(
22
+ use: (files: HashMap.HashMap<string, FileVersion>) => Effect.Effect<A, E, R>,
23
+ ) => Effect.Effect<A, E, R>
24
+
25
+ /** Observe the producer after its resource scope closes, before merge can discard its Exit. */
26
+ const observeSource = <A, E, R>(source: Stream.Stream<A, E, R>) =>
27
+ Stream.callback<A, E, R>(
28
+ (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(
37
+ "Native watcher failed during cancellation",
38
+ exit.cause,
39
+ )
40
+ : Effect.void,
41
+ ),
42
+ Effect.andThen(Queue.end(queue)),
43
+ ),
44
+ { bufferSize: 16 },
45
+ )
46
+
47
+ /** Reconcile loaded modules because native watchers can silently miss events. */
48
+ export const changes = (
49
+ withFiles: WithFiles,
50
+ paths: ReadonlyArray<string>,
51
+ options?: WatchOptions,
52
+ ) =>
53
+ Stream.unwrap(
54
+ Effect.gen(function* () {
55
+ if (paths.length === 0) return Stream.empty
56
+ const fs = yield* FileSystem.FileSystem
57
+ const roots = yield* Effect.forEach(paths, (path) =>
58
+ Effect.gen(function* () {
59
+ const canonical = yield* fs.realPath(path)
60
+ const info = yield* fs.stat(canonical)
61
+ return {
62
+ path: canonical,
63
+ urls: [
64
+ pathToFileURL(canonical).href,
65
+ pathToFileURL(resolve(path)).href,
66
+ ],
67
+ directory: info.type === "Directory",
68
+ }
69
+ }),
70
+ )
71
+ const accepted = (path: string) => options?.filter?.(path) ?? true
72
+ const watched = (url: string) =>
73
+ accepted(fileURLToPath(url)) &&
74
+ Array.some(roots, ({ urls }) =>
75
+ Array.some(urls, (root) => {
76
+ const directory = String.endsWith("/")(root) ? root : `${root}/`
77
+ return (
78
+ fileURLToPath(url) === fileURLToPath(root) ||
79
+ (options?.recursive === true
80
+ ? String.startsWith(directory)(url)
81
+ : new URL(".", url).href === directory)
82
+ )
83
+ }),
84
+ )
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
+ >(),
124
+ )
125
+ return Stream.mergeAll(
126
+ [
127
+ ...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
+ ),
137
+ ),
138
+ Stream.tick("1 second").pipe(
139
+ Stream.drop(1),
140
+ Stream.map(() => false),
141
+ ),
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
+ ]),
192
+ ),
193
+ ),
194
+ ),
195
+ Stream.filter(Boolean),
196
+ Stream.map(() => undefined),
197
+ )
198
+ }),
199
+ )
@@ -1,7 +1,16 @@
1
1
  // Node's synchronous module hooks have no Effect equivalent.
2
+ import { Base64 } from "effect/encoding"
2
3
  import { registerHooks } from "node:module"
3
- import { fileURLToPath } from "node:url"
4
- import { HashSet, Option, Predicate, Schema, String as Str } from "effect"
4
+ import { fileURLToPath, pathToFileURL } from "node:url"
5
+ import {
6
+ Array,
7
+ HashMap,
8
+ HashSet,
9
+ Option,
10
+ Predicate,
11
+ Schema,
12
+ String,
13
+ } from "effect"
5
14
 
6
15
  export class RestartRequired extends Schema.TaggedError<
7
16
  RestartRequired,
@@ -10,7 +19,7 @@ export class RestartRequired extends Schema.TaggedError<
10
19
 
11
20
  // Only a missing entry is disposable; missing dependencies still fail the reload.
12
21
  export const isMissingEntry = (url: string, cause: unknown): boolean =>
13
- Str.startsWith("file:")(url) &&
22
+ String.startsWith("file:")(url) &&
14
23
  Predicate.hasProperty(cause, "code") &&
15
24
  ((cause.code === "ERR_MODULE_NOT_FOUND" &&
16
25
  Predicate.hasProperty(cause, "url") &&
@@ -23,22 +32,144 @@ export const isMissingEntry = (url: string, cause: unknown): boolean =>
23
32
  let sessions = 0
24
33
 
25
34
  export interface Generation {
26
- readonly id: string
27
- unsupported: HashSet.HashSet<string>
35
+ readonly id: number
36
+ }
37
+
38
+ /** Bytes actually read by a particular native import attempt. */
39
+ export interface FileVersion {
40
+ readonly generation: number
41
+ readonly content: string | undefined
42
+ readonly resolution?: string
43
+ }
44
+
45
+ export interface Attempt {
46
+ readonly generation: Generation
47
+ readonly url: string
48
+ readonly request?: string
49
+ }
50
+
51
+ interface Dependency extends Attempt {
52
+ readonly parent: string
53
+ }
54
+
55
+ /** Resolve local requests lexically; Node's cached physical target is a separate fact. */
56
+ const fileRequest = (
57
+ specifier: string,
58
+ parent: string,
59
+ ): Option.Option<string> => {
60
+ if (
61
+ !Array.some(["file:", "./", "../", "/"], (prefix) =>
62
+ String.startsWith(prefix)(specifier),
63
+ )
64
+ )
65
+ return Option.none()
66
+ const url = new URL(specifier, parent)
67
+ return url.protocol === "file:" ? Option.some(url.href) : Option.none()
28
68
  }
29
69
 
30
70
  /** A scoped Node hook. Mutable bindings bridge synchronous hooks to Effect. */
31
71
  export const modules = (base: string, include: (url: string) => boolean) => {
32
72
  const key = `graft-hmr-${++sessions}`
33
73
  const prefix = `${key}:`
74
+ /** What `request` produces: the generation that imports a specifier. */
75
+ const decodeRequest = Schema.decodeUnknownOption(
76
+ Schema.TemplateLiteralParser([
77
+ prefix,
78
+ Schema.String.check(Schema.isPattern(/^\d+$/)),
79
+ ":",
80
+ Schema.String,
81
+ ]),
82
+ )
34
83
  let revision = 0
35
- let recording: Generation | undefined
36
- let reloading = false
84
+ let importing:
85
+ { readonly generation: Generation; readonly reload: boolean } | undefined
86
+ // Dependency facts outlive an import operation: apply may import modules later.
87
+ let dependencies = HashMap.empty<string, HashSet.HashSet<string>>()
88
+ let edges = HashMap.empty<
89
+ string,
90
+ HashMap.HashMap<string, HashMap.HashMap<string, Dependency>>
91
+ >()
92
+ const link = (
93
+ id: string,
94
+ parent: string,
95
+ child: string,
96
+ request?: string,
97
+ ) => {
98
+ const graph = Option.getOrElse(
99
+ HashMap.get(edges, id),
100
+ HashMap.empty<string, HashMap.HashMap<string, Dependency>>,
101
+ )
102
+ const children = Option.getOrElse(
103
+ HashMap.get(graph, parent),
104
+ HashMap.empty<string, Dependency>,
105
+ )
106
+ edges = HashMap.set(
107
+ edges,
108
+ id,
109
+ HashMap.set(
110
+ graph,
111
+ parent,
112
+ HashMap.set(children, request ?? child, {
113
+ generation: { id: Number(id) },
114
+ parent,
115
+ url: child,
116
+ ...(request === undefined ? {} : { request }),
117
+ }),
118
+ ),
119
+ )
120
+ }
121
+ const reachable = (id: string, roots: ReadonlyArray<string>) => {
122
+ const graph = Option.getOrElse(
123
+ HashMap.get(edges, id),
124
+ HashMap.empty<string, HashMap.HashMap<string, Dependency>>,
125
+ )
126
+ let seen = HashSet.empty<string>()
127
+ let pending = roots
128
+ while (Array.isReadonlyArrayNonEmpty(pending)) {
129
+ const [url, ...rest] = pending
130
+ pending = rest
131
+ if (HashSet.has(seen, url)) continue
132
+ seen = HashSet.add(seen, url)
133
+ pending = Array.appendAll(
134
+ pending,
135
+ Array.map(
136
+ HashMap.toValues(
137
+ Option.getOrElse(
138
+ HashMap.get(graph, url),
139
+ HashMap.empty<string, Dependency>,
140
+ ),
141
+ ),
142
+ (edge) => edge.url,
143
+ ),
144
+ )
145
+ }
146
+ return seen
147
+ }
148
+ // Both checks and polling derive resolution facts from the same owned graph.
149
+ const resolutionsFor = (attempt: Attempt): ReadonlyArray<Attempt> => {
150
+ const id = String.String(attempt.generation.id)
151
+ const used = reachable(id, [attempt.url])
152
+ const graph = Option.getOrElse(
153
+ HashMap.get(edges, id),
154
+ HashMap.empty<string, HashMap.HashMap<string, Dependency>>,
155
+ )
156
+ return [
157
+ ...(attempt.request === undefined ? [] : [attempt]),
158
+ ...Array.flatMap(HashMap.toValues(graph), (children) =>
159
+ Array.filter(
160
+ HashMap.toValues(children),
161
+ (edge) =>
162
+ edge.request !== undefined && HashSet.has(used, edge.parent),
163
+ ),
164
+ ),
165
+ ]
166
+ }
167
+
168
+ let sources = HashMap.empty<string, HashMap.HashMap<string, string>>()
37
169
  let resolved: string | undefined
38
170
 
39
171
  const prepare = (): Generation => ({
40
- id: String(++revision),
41
- unsupported: HashSet.empty(),
172
+ id: ++revision,
42
173
  })
43
174
  const clean = (url: string): string => {
44
175
  const value = new URL(url)
@@ -51,48 +182,105 @@ export const modules = (base: string, include: (url: string) => boolean) => {
51
182
  format === "commonjs" ||
52
183
  format === "commonjs-typescript" ||
53
184
  format === "addon" ||
54
- Str.endsWith(".node")(new URL(url).pathname)
185
+ String.endsWith(".node")(new URL(url).pathname)
186
+ const requireRestart = (url: string, id: string) => {
187
+ dependencies = HashMap.set(
188
+ dependencies,
189
+ id,
190
+ HashSet.add(
191
+ Option.getOrElse(HashMap.get(dependencies, id), HashSet.empty<string>),
192
+ url,
193
+ ),
194
+ )
195
+ if (
196
+ importing !== undefined &&
197
+ String.String(importing.generation.id) === id &&
198
+ importing.reload
199
+ )
200
+ throw RestartRequired.make({ url })
201
+ }
202
+
55
203
  const record = (
56
204
  url: string,
57
205
  id: string,
58
206
  format: string | null | undefined,
59
207
  ) => {
60
- if (recording?.id !== id) return
61
- if (unsupported(url, format)) {
62
- recording.unsupported = HashSet.add(recording.unsupported, url)
63
- if (reloading) throw RestartRequired.make({ url })
64
- }
208
+ if (unsupported(url, format)) requireRestart(url, id)
65
209
  }
66
210
 
67
211
  const hooks = registerHooks({
68
212
  resolve(specifier, context, nextResolve) {
69
- const entry = Str.startsWith(prefix)(specifier)
70
- const separator = !entry
71
- ? 0
72
- : prefix.length +
73
- Option.getOrThrow(
74
- Str.indexOf(":")(Str.slice(prefix.length)(specifier)),
75
- )
76
- const id = entry
77
- ? Str.slice(prefix.length, separator)(specifier)
78
- : version(context.parentURL)
79
- if (id === undefined || id === null)
80
- return nextResolve(specifier, context)
81
- const result = nextResolve(
82
- entry ? Str.slice(separator + 1)(specifier) : specifier,
83
- {
84
- ...context,
85
- parentURL: entry ? base : clean(context.parentURL!),
86
- },
87
- )
213
+ const request = decodeRequest(specifier)
214
+ const entry = Option.isSome(request)
215
+ const id = Option.match(request, {
216
+ onNone: () => version(context.parentURL),
217
+ onSome: ([, id]) => id,
218
+ })
219
+ if (id === null) return nextResolve(specifier, context)
220
+ const resolve = () =>
221
+ nextResolve(
222
+ Option.match(request, {
223
+ onNone: () => specifier,
224
+ onSome: ([, , , target]) => target,
225
+ }),
226
+ {
227
+ ...context,
228
+ parentURL: entry ? base : clean(context.parentURL!),
229
+ },
230
+ )
231
+ const result = (() => {
232
+ try {
233
+ return resolve()
234
+ } catch (cause) {
235
+ // Node reports the missing file even though resolution did not complete.
236
+ if (
237
+ Predicate.hasProperty(cause, "url") &&
238
+ typeof cause.url === "string" &&
239
+ String.startsWith("file:")(cause.url)
240
+ ) {
241
+ if (entry) resolved = cause.url
242
+ else if (context.parentURL !== undefined && include(cause.url))
243
+ link(
244
+ id,
245
+ clean(context.parentURL),
246
+ cause.url,
247
+ Option.getOrUndefined(
248
+ fileRequest(specifier, clean(context.parentURL)),
249
+ ),
250
+ )
251
+ }
252
+ throw cause
253
+ }
254
+ })()
88
255
  if (entry) resolved = result.url
256
+ // Acquiring node:module permits require calls that Node can satisfy
257
+ // without hooks. Record this capability before any cache can hide them.
89
258
  if (
90
- !Str.startsWith("file:")(result.url) ||
259
+ !entry &&
260
+ result.url === "node:module" &&
261
+ context.parentURL !== undefined
262
+ ) {
263
+ link(id, clean(context.parentURL), result.url)
264
+ requireRestart(result.url, id)
265
+ }
266
+ if (
267
+ !String.startsWith("file:")(result.url) ||
91
268
  (!entry && !include(result.url))
92
269
  )
93
270
  return result
94
- record(result.url, id, result.format)
95
- if (unsupported(result.url, result.format)) return result
271
+ if (!entry && context.parentURL !== undefined)
272
+ link(
273
+ id,
274
+ clean(context.parentURL),
275
+ result.url,
276
+ Option.getOrUndefined(
277
+ fileRequest(specifier, clean(context.parentURL)),
278
+ ),
279
+ )
280
+ const required = Array.contains(context.conditions, "require")
281
+ if (required) requireRestart(result.url, id)
282
+ else record(result.url, id, result.format)
283
+ if (required || unsupported(result.url, result.format)) return result
96
284
  const url = new URL(result.url)
97
285
  url.searchParams.set(key, id)
98
286
  return { ...result, url: url.href }
@@ -103,21 +291,126 @@ export const modules = (base: string, include: (url: string) => boolean) => {
103
291
  const original = clean(url)
104
292
  record(original, id, context.format)
105
293
  const result = nextLoad(original, context)
294
+ const source = result.source
295
+ if (source !== undefined && source !== null) {
296
+ const bytes =
297
+ typeof source === "string"
298
+ ? source
299
+ : ArrayBuffer.isView(source)
300
+ ? new Uint8Array(
301
+ source.buffer,
302
+ source.byteOffset,
303
+ source.byteLength,
304
+ )
305
+ : new Uint8Array(source)
306
+ sources = HashMap.set(
307
+ sources,
308
+ id,
309
+ HashMap.set(
310
+ Option.getOrElse(
311
+ HashMap.get(sources, id),
312
+ HashMap.empty<string, string>,
313
+ ),
314
+ original,
315
+ Base64.encode(bytes),
316
+ ),
317
+ )
318
+ }
106
319
  record(original, id, result.format)
107
320
  return result
108
321
  },
109
322
  })
110
323
  return {
111
324
  prepare,
325
+ fileRequest: (specifier: string) => fileRequest(specifier, base),
326
+ resolutions: (attempts: ReadonlyArray<Attempt>) =>
327
+ Array.flatMap(attempts, resolutionsFor),
328
+ files: (attempts: ReadonlyArray<Attempt>) =>
329
+ Array.reduce(
330
+ attempts,
331
+ HashMap.empty<string, FileVersion>(),
332
+ (files, { generation, url, request }) => {
333
+ const contents = Option.getOrElse(
334
+ HashMap.get(sources, String.String(generation.id)),
335
+ HashMap.empty<string, string>,
336
+ )
337
+ const unversioned = Option.getOrElse(
338
+ HashMap.get(dependencies, String.String(generation.id)),
339
+ HashSet.empty<string>,
340
+ )
341
+ const tracked = Array.reduce(
342
+ Array.fromIterable(reachable(String.String(generation.id), [url])),
343
+ files,
344
+ (files, url) => {
345
+ if (
346
+ !String.startsWith("file:")(url) ||
347
+ HashSet.has(unversioned, url)
348
+ )
349
+ return files
350
+ // A retained failed lease must not override a newer import
351
+ // of the same file just because its owner is visited last.
352
+ if (
353
+ Option.exists(
354
+ HashMap.get(files, url),
355
+ (current) =>
356
+ current.generation > generation.id ||
357
+ (current.generation === generation.id &&
358
+ current.resolution !== undefined),
359
+ )
360
+ )
361
+ return files
362
+ return HashMap.set(files, url, {
363
+ generation: generation.id,
364
+ content: Option.getOrUndefined(HashMap.get(contents, url)),
365
+ })
366
+ },
367
+ )
368
+ return Array.reduce(
369
+ resolutionsFor({
370
+ generation,
371
+ url,
372
+ ...(request === undefined ? {} : { request }),
373
+ }),
374
+ tracked,
375
+ (files, edge) => {
376
+ if (
377
+ edge.request === undefined ||
378
+ HashSet.has(unversioned, edge.url) ||
379
+ Option.exists(
380
+ HashMap.get(files, edge.request),
381
+ (file) => file.generation > generation.id,
382
+ )
383
+ )
384
+ return files
385
+ return HashMap.set(files, edge.request, {
386
+ generation: generation.id,
387
+ content: Option.getOrUndefined(HashMap.get(contents, edge.url)),
388
+ resolution: pathToFileURL(fileURLToPath(edge.url)).href,
389
+ })
390
+ },
391
+ )
392
+ },
393
+ ),
394
+ unsupported: (generation: Generation, roots?: ReadonlyArray<string>) => {
395
+ const found = Option.getOrElse(
396
+ HashMap.get(dependencies, String.String(generation.id)),
397
+ HashSet.empty<string>,
398
+ )
399
+ if (roots === undefined) return found
400
+ const used = reachable(String.String(generation.id), roots)
401
+ return HashSet.filter(found, (url) => HashSet.has(used, url))
402
+ },
112
403
  start: (generation: Generation, reload = false) => {
113
- recording = generation
114
- reloading = reload
404
+ importing = { generation, reload }
115
405
  },
116
406
  discard: () => {
117
- recording = undefined
407
+ importing = undefined
408
+ },
409
+ request: (specifier: string) => {
410
+ resolved = undefined
411
+ return `${prefix}${importing!.generation.id}:${specifier}`
118
412
  },
119
- request: (specifier: string) => `${prefix}${recording!.id}:${specifier}`,
120
- resolved: () => resolved,
413
+ resolved: () => Option.fromUndefinedOr(resolved),
121
414
  close: () => hooks.deregister(),
122
415
  }
123
416
  }