@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.
@@ -1,7 +1,17 @@
1
1
  // Node's synchronous module hooks have no Effect equivalent.
2
+ import { digest } from "./digest"
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
+ Chunk,
8
+ HashMap,
9
+ HashSet,
10
+ Option,
11
+ Predicate,
12
+ Schema,
13
+ String,
14
+ } from "effect"
5
15
 
6
16
  export class RestartRequired extends Schema.TaggedError<
7
17
  RestartRequired,
@@ -10,7 +20,7 @@ export class RestartRequired extends Schema.TaggedError<
10
20
 
11
21
  // Only a missing entry is disposable; missing dependencies still fail the reload.
12
22
  export const isMissingEntry = (url: string, cause: unknown): boolean =>
13
- Str.startsWith("file:")(url) &&
23
+ String.startsWith("file:")(url) &&
14
24
  Predicate.hasProperty(cause, "code") &&
15
25
  ((cause.code === "ERR_MODULE_NOT_FOUND" &&
16
26
  Predicate.hasProperty(cause, "url") &&
@@ -22,102 +32,438 @@ export const isMissingEntry = (url: string, cause: unknown): boolean =>
22
32
  // Allocated only when a session starts; importing this module performs no I/O.
23
33
  let sessions = 0
24
34
 
35
+ /** A resolution view that allocates fresh URLs for new or invalidated instances. */
25
36
  export interface Generation {
26
- readonly id: string
27
- unsupported: HashSet.HashSet<string>
37
+ readonly id: number
38
+ }
39
+
40
+ /** Digest of the bytes actually loaded by a concrete module instance. */
41
+ export interface FileVersion {
42
+ readonly generation: number
43
+ readonly content: string | undefined
44
+ readonly resolution?: string
45
+ }
46
+
47
+ export interface Attempt {
48
+ readonly generation: Generation
49
+ readonly url: string
50
+ readonly request?: string
51
+ }
52
+
53
+ interface Dependency extends Attempt {
54
+ readonly node: string
55
+ }
56
+
57
+ /** Resolve local requests lexically; Node's cached physical target is a separate fact. */
58
+ const fileRequest = (
59
+ specifier: string,
60
+ parent: string,
61
+ ): Option.Option<string> => {
62
+ if (
63
+ !Array.some(["file:", "./", "../", "/"], (prefix) =>
64
+ String.startsWith(prefix)(specifier),
65
+ )
66
+ )
67
+ return Option.none()
68
+ const url = new URL(specifier, parent)
69
+ return url.protocol === "file:" ? Option.some(url.href) : Option.none()
28
70
  }
29
71
 
30
72
  /** A scoped Node hook. Mutable bindings bridge synchronous hooks to Effect. */
31
73
  export const modules = (base: string, include: (url: string) => boolean) => {
32
74
  const key = `graft-hmr-${++sessions}`
33
75
  const prefix = `${key}:`
76
+ const decodeRequest = Schema.decodeUnknownOption(
77
+ Schema.TemplateLiteralParser([
78
+ prefix,
79
+ Schema.String.check(Schema.isPattern(/^\d+$/)),
80
+ ":",
81
+ Schema.String,
82
+ ]),
83
+ )
34
84
  let revision = 0
35
- let recording: Generation | undefined
36
- let reloading = false
85
+ let importing:
86
+ { readonly generation: Generation; readonly reload: boolean } | undefined
37
87
  let resolved: string | undefined
38
-
39
- const prepare = (): Generation => ({
40
- id: String(++revision),
41
- unsupported: HashSet.empty(),
42
- })
88
+ // Resolution views contain only routing identities. Old code can execute after
89
+ // replacement (including teardown), so these persistent maps live until close.
90
+ // Cached instances also retain their facts: Node does not replay static imports.
91
+ // Leases restrict observation, not the lifetime of old routing targets.
92
+ let views = HashMap.empty<number, HashMap.HashMap<string, string>>()
93
+ let edges = HashMap.empty<string, HashMap.HashMap<string, Dependency>>()
94
+ let sources = HashMap.empty<string, string>()
95
+ let dependencies = HashSet.empty<string>()
96
+ let reachCache = HashMap.empty<string, HashSet.HashSet<string>>()
97
+ let retained = HashSet.empty<string>()
98
+ let instantiatedViews = HashSet.empty<number>()
43
99
  const clean = (url: string): string => {
44
100
  const value = new URL(url)
45
101
  value.searchParams.delete(key)
46
102
  return value.href
47
103
  }
48
- const version = (url: string | undefined): string | null =>
49
- url === undefined ? null : new URL(url).searchParams.get(key)
104
+ const version = (url: string | undefined): number | undefined => {
105
+ if (url === undefined) return undefined
106
+ const value = new URL(url).searchParams.get(key)
107
+ return value === null ? undefined : Number(value)
108
+ }
109
+ const tag = (url: string, id: number) => {
110
+ const value = new URL(url)
111
+ value.searchParams.set(key, String.String(id))
112
+ return value.href
113
+ }
114
+ const route = (url: string, id: number): string =>
115
+ Option.getOrElse(
116
+ HashMap.get(
117
+ Option.getOrElse(HashMap.get(views, id), HashMap.empty<string, string>),
118
+ url,
119
+ ),
120
+ () => tag(url, id),
121
+ )
122
+
123
+ const root = (attempt: Attempt) => route(attempt.url, attempt.generation.id)
124
+ const children = (node: string) =>
125
+ Option.getOrElse(
126
+ HashMap.get(edges, node),
127
+ HashMap.empty<string, Dependency>,
128
+ )
129
+ const reachable = (node: string) => {
130
+ const cached = HashMap.get(reachCache, node)
131
+ if (Option.isSome(cached)) return cached.value
132
+ let seen = HashSet.empty<string>()
133
+ let pending: Chunk.Chunk<string> = Chunk.of(node)
134
+ while (Chunk.isNonEmpty(pending)) {
135
+ const current = Chunk.headNonEmpty(pending)
136
+ pending = Chunk.drop(pending, 1)
137
+ if (HashSet.has(seen, current)) continue
138
+ seen = HashSet.add(seen, current)
139
+ for (const edge of HashMap.values(children(current)))
140
+ pending = Chunk.prepend(pending, edge.node)
141
+ }
142
+ reachCache = HashMap.set(reachCache, node, seen)
143
+ return seen
144
+ }
145
+ const usedBy = (attempts: ReadonlyArray<Attempt>) => {
146
+ const roots = HashSet.fromIterable(Array.map(attempts, root))
147
+ let used = HashSet.empty<string>()
148
+ for (const node of roots) used = HashSet.union(used, reachable(node))
149
+ return used
150
+ }
151
+ const link = (parent: string, node: string, request?: string) => {
152
+ if (
153
+ importing === undefined &&
154
+ !HashSet.has(retained, parent) &&
155
+ !HashSet.has(instantiatedViews, version(parent)!)
156
+ )
157
+ return
158
+ retained = HashSet.add(retained, node)
159
+ const url = clean(node)
160
+ const edge = {
161
+ node,
162
+ url,
163
+ generation: { id: version(node)! },
164
+ ...(request === undefined ? {} : { request }),
165
+ }
166
+ const previous = HashMap.get(children(parent), request ?? url)
167
+ if (
168
+ Option.exists(
169
+ previous,
170
+ (previous) => previous.node === node && previous.request === request,
171
+ )
172
+ )
173
+ return
174
+ edges = HashMap.set(
175
+ edges,
176
+ parent,
177
+ HashMap.set(children(parent), request ?? url, edge),
178
+ )
179
+ reachCache = HashMap.empty()
180
+ }
181
+ const resolutions = (
182
+ attempts: ReadonlyArray<Attempt>,
183
+ ): ReadonlyArray<Attempt> => [
184
+ ...Array.filter(attempts, (attempt) => attempt.request !== undefined),
185
+ ...Array.flatMap(Array.fromIterable(usedBy(attempts)), (node) =>
186
+ Array.filter(
187
+ HashMap.toValues(children(node)),
188
+ (edge) => edge.request !== undefined,
189
+ ),
190
+ ),
191
+ ]
50
192
  const unsupported = (url: string, format: string | null | undefined) =>
51
193
  format === "commonjs" ||
52
194
  format === "commonjs-typescript" ||
53
195
  format === "addon" ||
54
- Str.endsWith(".node")(new URL(url).pathname)
55
- const record = (
56
- url: string,
57
- id: string,
58
- format: string | null | undefined,
196
+ String.endsWith(".node")(new URL(url).pathname)
197
+ const requireRestart = (node: string) => {
198
+ if (importing !== undefined || HashSet.has(retained, node))
199
+ dependencies = HashSet.add(dependencies, node)
200
+ if (importing?.reload === true && importing.generation.id === version(node))
201
+ throw RestartRequired.make({ url: clean(node) })
202
+ }
203
+ const record = (node: string, format: string | null | undefined) => {
204
+ if (unsupported(clean(node), format)) requireRestart(node)
205
+ }
206
+ // One reverse traversal closes cycles and all importers of every dirty instance.
207
+ const invalidated = (
208
+ attempts: ReadonlyArray<Attempt>,
209
+ dirty: HashSet.HashSet<string>,
59
210
  ) => {
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 })
211
+ const used = usedBy(attempts)
212
+ let parents = HashMap.empty<string, HashSet.HashSet<string>>()
213
+ let pending = Chunk.empty<string>()
214
+ for (const node of used) {
215
+ if (HashSet.has(dirty, clean(node)))
216
+ pending = Chunk.prepend(pending, node)
217
+ for (const edge of HashMap.values(children(node))) {
218
+ parents = HashMap.set(
219
+ parents,
220
+ edge.node,
221
+ HashSet.add(
222
+ Option.getOrElse(
223
+ HashMap.get(parents, edge.node),
224
+ HashSet.empty<string>,
225
+ ),
226
+ node,
227
+ ),
228
+ )
229
+ if (edge.request !== undefined && HashSet.has(dirty, edge.request))
230
+ pending = Chunk.prepend(pending, edge.node)
231
+ }
64
232
  }
233
+ for (const attempt of attempts)
234
+ if (attempt.request !== undefined && HashSet.has(dirty, attempt.request))
235
+ pending = Chunk.prepend(pending, root(attempt))
236
+ let invalid = HashSet.empty<string>()
237
+ while (Chunk.isNonEmpty(pending)) {
238
+ const node = Chunk.headNonEmpty(pending)
239
+ pending = Chunk.drop(pending, 1)
240
+ if (HashSet.has(invalid, node)) continue
241
+ invalid = HashSet.add(invalid, node)
242
+ for (const parent of Option.getOrElse(
243
+ HashMap.get(parents, node),
244
+ HashSet.empty<string>,
245
+ ))
246
+ pending = Chunk.prepend(pending, parent)
247
+ }
248
+ return invalid
65
249
  }
250
+ const prepare = (
251
+ attempts: ReadonlyArray<Attempt> = [],
252
+ invalid = HashSet.empty<string>(),
253
+ ): Generation => {
254
+ const id = ++revision
255
+ const seed = Array.reduce(attempts, 0, (id, attempt) =>
256
+ Math.max(id, attempt.generation.id),
257
+ )
258
+ let view = Option.getOrElse(
259
+ HashMap.get(views, seed),
260
+ HashMap.empty<string, string>,
261
+ )
262
+ const used = usedBy(attempts)
263
+ // Retain HAMT structure between views. Default routes need no stored entry;
264
+ // only modules reused from older views need an explicit override.
265
+ for (const [url, node] of view)
266
+ if (!HashSet.has(used, node) || HashSet.has(invalid, node))
267
+ view = HashMap.remove(view, url)
268
+ for (const node of used) {
269
+ if (HashSet.has(invalid, node)) continue
270
+ const url = clean(node)
271
+ const previous = HashMap.get(view, url)
272
+ if (Option.isNone(previous) || version(previous.value)! < version(node)!)
273
+ view = HashMap.set(view, url, node)
274
+ }
66
275
 
276
+ views = HashMap.set(views, id, view)
277
+ return { id }
278
+ }
67
279
  const hooks = registerHooks({
68
280
  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
- )
281
+ const parentNode = context.parentURL
282
+ const request = decodeRequest(specifier)
283
+ const entry = Option.isSome(request)
284
+ const id = Option.match(request, {
285
+ onNone: () => version(context.parentURL),
286
+ onSome: ([, id]) => Number(id),
287
+ })
288
+ if (id === undefined) return nextResolve(specifier, context)
289
+ const target = Option.match(request, {
290
+ onNone: () => specifier,
291
+ onSome: ([, , , target]) => target,
292
+ })
293
+ const parent = entry ? base : clean(context.parentURL!)
294
+ const lexical = Option.getOrUndefined(fileRequest(target, parent))
295
+ const result = (() => {
296
+ try {
297
+ return nextResolve(target, { ...context, parentURL: parent })
298
+ } catch (cause) {
299
+ if (
300
+ Predicate.hasProperty(cause, "url") &&
301
+ typeof cause.url === "string" &&
302
+ String.startsWith("file:")(cause.url)
303
+ ) {
304
+ if (entry) resolved = cause.url
305
+ else if (include(cause.url))
306
+ link(parentNode!, route(cause.url, id), lexical)
307
+ }
308
+ throw cause
309
+ }
310
+ })()
88
311
  if (entry) resolved = result.url
312
+ if (!entry && result.url === "node:module") {
313
+ const node = route(result.url, id)
314
+ link(parentNode!, node)
315
+ requireRestart(node)
316
+ }
89
317
  if (
90
- !Str.startsWith("file:")(result.url) ||
318
+ !String.startsWith("file:")(result.url) ||
91
319
  (!entry && !include(result.url))
92
320
  )
93
321
  return result
94
- record(result.url, id, result.format)
95
- if (unsupported(result.url, result.format)) return result
96
- const url = new URL(result.url)
97
- url.searchParams.set(key, id)
98
- return { ...result, url: url.href }
322
+ const node = route(result.url, id)
323
+ if (!entry) link(parentNode!, node, lexical)
324
+ const required = Array.contains(context.conditions, "require")
325
+ if (required) requireRestart(node)
326
+ else record(node, result.format)
327
+ return required || unsupported(result.url, result.format)
328
+ ? result
329
+ : { ...result, url: node }
99
330
  },
100
331
  load(url, context, nextLoad) {
101
332
  const id = version(url)
102
- if (id === null) return nextLoad(url, context)
333
+ if (id === undefined) return nextLoad(url, context)
334
+ instantiatedViews = HashSet.add(instantiatedViews, id)
103
335
  const original = clean(url)
104
- record(original, id, context.format)
336
+ record(url, context.format)
105
337
  const result = nextLoad(original, context)
106
- record(original, id, result.format)
338
+ const source = result.source
339
+ if (source !== undefined && source !== null) {
340
+ const bytes =
341
+ typeof source === "string"
342
+ ? source
343
+ : ArrayBuffer.isView(source)
344
+ ? new Uint8Array(
345
+ source.buffer,
346
+ source.byteOffset,
347
+ source.byteLength,
348
+ )
349
+ : new Uint8Array(source)
350
+ if (importing !== undefined || HashSet.has(retained, url))
351
+ sources = HashMap.set(sources, url, digest(bytes))
352
+ }
353
+ record(url, result.format)
107
354
  return result
108
355
  },
109
356
  })
357
+ const files = (attempts: ReadonlyArray<Attempt>) => {
358
+ let files = HashMap.empty<string, FileVersion>()
359
+ const put = (url: string, node: string, resolution?: string) => {
360
+ if (!String.startsWith("file:")(url) || HashSet.has(dependencies, node))
361
+ return
362
+ const generation = version(node)!
363
+ const previous = HashMap.get(files, url)
364
+ if (
365
+ Option.exists(
366
+ previous,
367
+ (previous) =>
368
+ previous.generation > generation ||
369
+ (previous.generation === generation &&
370
+ previous.resolution !== undefined),
371
+ )
372
+ )
373
+ return
374
+ files = HashMap.set(files, url, {
375
+ generation,
376
+ content: Option.getOrUndefined(HashMap.get(sources, node)),
377
+ ...(resolution === undefined ? {} : { resolution }),
378
+ })
379
+ }
380
+ for (const node of usedBy(attempts)) put(clean(node), node)
381
+ for (const attempt of resolutions(attempts))
382
+ if (attempt.request !== undefined)
383
+ put(
384
+ attempt.request,
385
+ root(attempt),
386
+ pathToFileURL(fileURLToPath(attempt.url)).href,
387
+ )
388
+ return files
389
+ }
110
390
  return {
111
391
  prepare,
392
+ files,
393
+ resolutions,
394
+ invalidated,
395
+ affected: (attempt: Attempt, invalid: HashSet.HashSet<string>) =>
396
+ HashSet.has(invalid, root(attempt)),
397
+ fileRequest: (specifier: string) => fileRequest(specifier, base),
398
+ unsupported: (generation: Generation, roots?: ReadonlyArray<string>) => {
399
+ const used =
400
+ roots === undefined
401
+ ? undefined
402
+ : usedBy(Array.map(roots, (url) => ({ url, generation })))
403
+ return HashSet.fromIterable(
404
+ Array.map(
405
+ Array.filter(Array.fromIterable(dependencies), (node) =>
406
+ used === undefined
407
+ ? version(node) === generation.id
408
+ : HashSet.has(used, node),
409
+ ),
410
+ clean,
411
+ ),
412
+ )
413
+ },
414
+ collect: (attempts: ReadonlyArray<Attempt>) => {
415
+ const used = usedBy(attempts)
416
+ const ownedViews = HashSet.fromIterable(
417
+ Array.map(attempts, (attempt) => attempt.generation.id),
418
+ )
419
+ views = HashMap.filter(
420
+ views,
421
+ (_, id) =>
422
+ id === revision ||
423
+ HashSet.has(instantiatedViews, id) ||
424
+ HashSet.has(ownedViews, id),
425
+ )
426
+ // Old code can reach any default route in its instantiated view, even
427
+ // after every lease on that target closed. Preserve its complete graph,
428
+ // source baseline and native capabilities until the session closes.
429
+ const keep = (node: string) =>
430
+ HashSet.has(used, node) ||
431
+ HashSet.has(instantiatedViews, version(node)!)
432
+ retained = used
433
+ sources = HashMap.filter(sources, (_, node) => keep(node))
434
+ edges = HashMap.filter(edges, (_, node) => keep(node))
435
+ dependencies = HashSet.filter(dependencies, keep)
436
+ reachCache = HashMap.filter(reachCache, (_, node) =>
437
+ HashSet.has(used, node),
438
+ )
439
+ },
440
+ // Internal counters support deterministic retention tests and external benchmarks.
441
+ counts: () => ({
442
+ sources: HashMap.size(sources),
443
+ edges: HashMap.size(edges),
444
+ dependencies: HashSet.size(dependencies),
445
+ views: HashMap.size(views),
446
+ }),
112
447
  start: (generation: Generation, reload = false) => {
113
- recording = generation
114
- reloading = reload
448
+ importing = { generation, reload }
115
449
  },
116
450
  discard: () => {
117
- recording = undefined
451
+ importing = undefined
452
+ },
453
+ request: (specifier: string) => {
454
+ resolved = undefined
455
+ return `${prefix}${importing!.generation.id}:${specifier}`
456
+ },
457
+ resolved: () => Option.fromUndefinedOr(resolved),
458
+ close: () => {
459
+ hooks.deregister()
460
+ views = HashMap.empty()
461
+ sources = HashMap.empty()
462
+ edges = HashMap.empty()
463
+ dependencies = HashSet.empty()
464
+ reachCache = HashMap.empty()
465
+ retained = HashSet.empty()
466
+ instantiatedViews = HashSet.empty()
118
467
  },
119
- request: (specifier: string) => `${prefix}${recording!.id}:${specifier}`,
120
- resolved: () => resolved,
121
- close: () => hooks.deregister(),
122
468
  }
123
469
  }