@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.
package/src/index.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Hot module replacement for Graft plugins on Node.js.
3
3
  */
4
+ // @effect-diagnostics-next-line nodeBuiltinImport:off -- native import resolution must work without requiring a platform layer
5
+ import { realpath } from "node:fs/promises"
6
+ import { fileURLToPath, pathToFileURL } from "node:url"
4
7
  import { Graft, Plugin } from "@graftland/graft"
5
8
  import {
6
- Array as Arr,
9
+ Array,
7
10
  Cause,
11
+ Data,
12
+ Deferred,
8
13
  Effect,
9
14
  Exit,
10
15
  FiberSet,
@@ -12,13 +17,22 @@ import {
12
17
  HashMap,
13
18
  HashSet,
14
19
  Option,
15
- String as Str,
20
+ Ref,
21
+ Result,
22
+ String,
16
23
  Schema,
17
24
  Semaphore,
18
25
  Stream,
26
+ Scope,
19
27
  } from "effect"
20
- import type { Duration, PlatformError, Scope } from "effect"
21
- import { isMissingEntry, modules, RestartRequired } from "./internal/modules"
28
+ import type { Duration, PlatformError } from "effect"
29
+ import { changes } from "./internal/changes"
30
+ import {
31
+ isMissingEntry,
32
+ modules,
33
+ RestartRequired,
34
+ type Attempt,
35
+ } from "./internal/modules"
22
36
 
23
37
  export class HmrError extends Schema.TaggedError<
24
38
  HmrError,
@@ -33,20 +47,19 @@ export class HmrError extends Schema.TaggedError<
33
47
  }
34
48
  }
35
49
 
36
- export type ReloadResult =
37
- | { readonly _tag: "Reloaded"; readonly entries: ReadonlyArray<string> }
38
- | {
39
- readonly _tag: "Skipped"
40
- readonly modules: ReadonlyArray<string>
41
- readonly reason: "restart-required"
42
- }
43
-
44
- export type WatchResult =
45
- | ReloadResult
46
- | {
47
- readonly _tag: "Failed"
48
- readonly cause: Cause.Cause<HmrError>
49
- }
50
+ export type WatchResult = Data.TaggedEnum<{
51
+ Reloaded: { readonly entries: ReadonlyArray<string> }
52
+ Skipped: {
53
+ readonly modules: ReadonlyArray<string>
54
+ readonly reason:
55
+ "restart-required" | "ambiguous-exports" | "identity-conflict"
56
+ }
57
+ Failed: { readonly cause: Cause.Cause<HmrError> }
58
+ }>
59
+
60
+ export const WatchResult = Data.taggedEnum<WatchResult>()
61
+
62
+ export type ReloadResult = Exclude<WatchResult, { readonly _tag: "Failed" }>
50
63
 
51
64
  export interface Options {
52
65
  /** Application module URL, usually import.meta.url; used for bare and relative resolution. */
@@ -55,15 +68,35 @@ export interface Options {
55
68
  readonly include?: (url: string) => boolean
56
69
  }
57
70
 
71
+ export interface WatchOptions extends FileSystem.WatchOptions {
72
+ readonly debounce?: Duration.Input
73
+ /** Receives an absolute file path; applies to native events and reconciliation. */
74
+ readonly filter?: (path: string) => boolean
75
+ }
76
+
77
+ /** Separates retry observations from an importer's temporary resource scope. */
78
+ export interface ImportOptions {
79
+ readonly observationScope: Scope.Scope
80
+ }
81
+
58
82
  export interface Hmr {
59
- /** Pass directly to ModuleResolver.layer({ base, importer }). */
83
+ /** Keeps imported entries registered until this HMR session closes. */
60
84
  readonly importer: (specifier: string) => Promise<unknown>
85
+ /** Registers an observation lease in observationScope, or the caller's scope when omitted. */
86
+ readonly importerScoped: (
87
+ specifier: string,
88
+ options?: ImportOptions,
89
+ ) => Effect.Effect<unknown, HmrError, Scope.Scope>
61
90
  /** Imports every entry before replacing anything. Activation failures belong to Graft. */
62
91
  readonly reload: Effect.Effect<ReloadResult, HmrError>
63
- /** Consuming this stream installs the watchers; the consumer owns their lifetime. */
92
+ /**
93
+ * Consuming this stream installs graph-driven directory watchers, polls stat once
94
+ * per second, and audits contents every ten seconds to recover missed events. Known native loading limitations emit
95
+ * Skipped even without a file event. The consumer owns the stream's lifetimes.
96
+ */
64
97
  readonly watch: (
65
98
  paths: ReadonlyArray<string>,
66
- options?: FileSystem.WatchOptions & { readonly debounce?: Duration.Input },
99
+ options?: WatchOptions,
67
100
  ) => Stream.Stream<
68
101
  WatchResult,
69
102
  PlatformError.PlatformError,
@@ -81,40 +114,197 @@ export const make = (
81
114
  Effect.gen(function* () {
82
115
  const graft = yield* Graft.Graft
83
116
  const semaphore = yield* Semaphore.make(1)
84
- let closed = false
117
+ // A lease owns both the running graph and its latest uncommitted attempt.
118
+ const owners = yield* Ref.make(
119
+ HashMap.empty<
120
+ symbol,
121
+ {
122
+ readonly active: Option.Option<Attempt>
123
+ readonly pending: Option.Option<Attempt>
124
+ }
125
+ >(),
126
+ )
127
+ const closed = yield* Ref.make(false)
128
+ const session = yield* Effect.scope
129
+ // Transfer the complete Exit before restoring interruption. Effect may replace
130
+ // a failed masked operation with pending interruption at that boundary.
131
+ const preserveExit = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
132
+ Effect.gen(function* () {
133
+ const completed = yield* Deferred.make<Exit.Exit<A, E>>()
134
+ return yield* effect.pipe(
135
+ Effect.exit,
136
+ Effect.tap((exit) => Deferred.succeed(completed, exit)),
137
+ Effect.uninterruptible,
138
+ Effect.flatten,
139
+ Effect.onExit((exit) =>
140
+ Effect.gen(function* () {
141
+ if (Exit.isSuccess(exit) || !Cause.hasInterrupts(exit.cause))
142
+ return
143
+ const saved = yield* Deferred.poll(completed)
144
+ if (Option.isNone(saved)) return
145
+ const original = yield* saved.value
146
+ if (Exit.isFailure(original))
147
+ yield* Effect.logError(
148
+ "HMR operation failed during cancellation",
149
+ original.cause,
150
+ )
151
+ }),
152
+ ),
153
+ )
154
+ })
85
155
  // Native imports cannot be cancelled: keep the lock until their promises settle.
86
156
  const serial = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
87
- semaphore.withPermits(1)(Effect.uninterruptible(effect))
157
+ effect.pipe(preserveExit, semaphore.withPermits(1))
88
158
  const hooks = yield* Effect.acquireRelease(
89
159
  Effect.sync(() =>
90
160
  modules(
91
161
  options.base,
92
162
  options.include ??
93
- ((url) => !Str.includes("/node_modules/")(new URL(url).pathname)),
163
+ ((url) =>
164
+ !String.includes("/node_modules/")(new URL(url).pathname)),
94
165
  ),
95
166
  ),
96
167
  (hooks) =>
97
168
  serial(
98
- Effect.sync(() => {
99
- closed = true
100
- hooks.close()
101
- }),
169
+ Ref.set(closed, true).pipe(Effect.andThen(Effect.sync(hooks.close))),
102
170
  ),
103
171
  )
104
- let generation = hooks.prepare()
105
- let entries = HashMap.empty<string, Plugin.Plugin>()
172
+ // Node can return a cached entry after its last observation lease closes.
173
+ // Keep current entry attempts without polling or reloading unleased entries.
174
+ // The hooks also retain cached instance facts for older resolution views.
175
+ const cached = yield* Ref.make(HashMap.empty<string, Attempt>())
176
+
177
+ const retryDirty = yield* Ref.make(HashSet.empty<string>())
178
+ const retryAll = yield* Ref.make(false)
179
+ const observations = Ref.get(owners).pipe(
180
+ Effect.map((owners) =>
181
+ Array.flatMap(HashMap.toValues(owners), (owner) =>
182
+ Array.getSomes([owner.active, owner.pending]),
183
+ ),
184
+ ),
185
+ )
186
+ const collect = Effect.gen(function* () {
187
+ const attempts = yield* observations
188
+ const currentCache = yield* Ref.get(cached)
189
+ hooks.collect([...attempts, ...HashMap.toValues(currentCache)])
190
+ const files = hooks.files(attempts)
191
+ yield* Ref.update(
192
+ retryDirty,
193
+ HashSet.filter((url) => HashMap.has(files, url)),
194
+ )
195
+ if (attempts.length === 0) yield* Ref.set(retryAll, false)
196
+ })
197
+
198
+ const state = yield* Ref.make({
199
+ generation: hooks.prepare(),
200
+ entries: HashMap.empty<
201
+ string,
202
+ {
203
+ readonly plugin: Plugin.Plugin
204
+ readonly leases: HashSet.HashSet<symbol>
205
+ }
206
+ >(),
207
+ })
208
+ // Stop Promise-backed operations before releasing their leases. Otherwise
209
+ // a lease finalizer could wait on an import before its owner interrupts it.
210
+ const imports = yield* Scope.fork(session)
106
211
  const run = yield* FiberSet.makeRuntimePromise<never, unknown, HmrError>()
107
- const checkOpen = Effect.suspend(() =>
108
- closed
109
- ? Effect.fail(
110
- HmrError.make({
111
- stage: "closed",
112
- specifier: "",
113
- cause: "HMR scope is closed",
114
- }),
115
- )
116
- : Effect.void,
212
+ const closedError = () =>
213
+ HmrError.make({
214
+ stage: "closed",
215
+ specifier: "",
216
+ cause: "HMR scope is closed",
217
+ })
218
+ const checkOpen = Ref.get(closed).pipe(
219
+ Effect.flatMap((closed) =>
220
+ closed ? Effect.fail(closedError()) : Effect.void,
221
+ ),
117
222
  )
223
+ const physicalTarget = (request: string) =>
224
+ Effect.tryPromise({
225
+ try: () => realpath(fileURLToPath(request)),
226
+ catch: (cause) =>
227
+ HmrError.make({ stage: "import", specifier: request, cause }),
228
+ }).pipe(
229
+ Effect.map((path) => pathToFileURL(path).href),
230
+ Effect.catchIf(
231
+ (error) => isMissingEntry(request, error.cause),
232
+ () => Effect.undefined,
233
+ ),
234
+ )
235
+ const changedResolution = Effect.fnUntraced(function* (
236
+ attempt: Pick<Attempt, "url" | "request">,
237
+ ) {
238
+ if (attempt.request === undefined) return Option.none<string>()
239
+ const request = attempt.request
240
+ const expected = pathToFileURL(fileURLToPath(attempt.url)).href
241
+ const actual = yield* physicalTarget(request)
242
+ if (actual === expected) return Option.none<string>()
243
+ // If both paths disappeared, this is ordinary entry deletion. A
244
+ // missing link with a surviving old target must never reload that target.
245
+ if (actual === undefined) {
246
+ const remaining = yield* physicalTarget(attempt.url)
247
+ if (remaining === undefined) return Option.none<string>()
248
+ }
249
+ return Option.some(request)
250
+ })
251
+
252
+ const changedResolutions = Effect.gen(function* () {
253
+ const current = yield* Ref.get(owners)
254
+ const attempts = hooks.resolutions(
255
+ Array.flatMap(HashMap.toValues(current), (owner) =>
256
+ Array.getSomes([owner.active, owner.pending]),
257
+ ),
258
+ )
259
+ const requests = HashSet.fromIterable(
260
+ Array.filterMap(attempts, (attempt) =>
261
+ attempt.request === undefined
262
+ ? Result.fail(undefined)
263
+ : Result.succeed(attempt.request),
264
+ ),
265
+ )
266
+ const resolveAll = (requests: HashSet.HashSet<string>) =>
267
+ Effect.forEach(
268
+ requests,
269
+ (request) =>
270
+ physicalTarget(request).pipe(
271
+ Effect.map((target) => [request, target] as const),
272
+ ),
273
+ { concurrency: 8 },
274
+ ).pipe(Effect.map(HashMap.fromIterable))
275
+ const actual = yield* resolveAll(requests)
276
+ const missingTargets = HashSet.fromIterable(
277
+ Array.filterMap(attempts, (attempt) =>
278
+ attempt.request !== undefined &&
279
+ Option.getOrUndefined(HashMap.get(actual, attempt.request)) ===
280
+ undefined &&
281
+ !HashMap.has(actual, attempt.url)
282
+ ? Result.succeed(attempt.url)
283
+ : Result.fail(undefined),
284
+ ),
285
+ )
286
+ const remaining = yield* resolveAll(missingTargets)
287
+ const targets = HashMap.union(actual, remaining)
288
+
289
+ return Array.fromIterable(
290
+ HashSet.fromIterable(
291
+ Array.filterMap(attempts, (attempt) => {
292
+ if (attempt.request === undefined) return Result.fail(undefined)
293
+ const actual = Option.getOrUndefined(
294
+ HashMap.get(targets, attempt.request),
295
+ )
296
+ const expected = pathToFileURL(fileURLToPath(attempt.url)).href
297
+ return actual === expected ||
298
+ (actual === undefined &&
299
+ Option.getOrUndefined(HashMap.get(targets, attempt.url)) ===
300
+ undefined)
301
+ ? Result.fail(undefined)
302
+ : Result.succeed(attempt.request)
303
+ }),
304
+ ),
305
+ )
306
+ })
307
+
118
308
  const load = Effect.fnUntraced(function* (specifier: string) {
119
309
  const namespace = yield* Effect.tryPromise({
120
310
  try: () => import(/* @vite-ignore */ hooks.request(specifier)),
@@ -127,88 +317,422 @@ export const make = (
127
317
  specifier,
128
318
  cause: "Expected a default or named plugin export",
129
319
  })
130
- return { namespace, plugin: plugin.value, url: hooks.resolved()! }
320
+ const url = yield* Effect.fromOption(hooks.resolved()).pipe(
321
+ Effect.mapError((cause) =>
322
+ HmrError.make({ stage: "import", specifier, cause }),
323
+ ),
324
+ )
325
+ const request = hooks.fileRequest(specifier)
326
+ const attempt = {
327
+ url,
328
+ ...Option.match(request, {
329
+ onNone: () => ({}),
330
+ onSome: (request) => ({ request }),
331
+ }),
332
+ }
333
+ const changed = yield* changedResolution(attempt)
334
+ if (Option.isSome(changed))
335
+ return yield* HmrError.make({
336
+ stage: "import",
337
+ specifier,
338
+ cause: RestartRequired.make({ url: changed.value }),
339
+ })
340
+ return { namespace, plugin: plugin.value, url }
131
341
  })
342
+ const importWithLease = (specifier: string) =>
343
+ Effect.acquireRelease(
344
+ serial(
345
+ Effect.gen(function* () {
346
+ yield* checkOpen
347
+ const current = yield* Ref.get(state)
348
+ hooks.start(current.generation)
349
+ // Acquire an owner for either Exit. Rethrow only after its finalizer
350
+ // is registered, so failed imports remain available for scoped retry.
351
+ const exit = yield* Effect.exit(load(specifier))
352
+ const token: symbol = Symbol()
353
+ const root = Option.orElse(hooks.resolved(), () =>
354
+ String.startsWith("file:")(specifier)
355
+ ? Option.some(specifier)
356
+ : Option.none(),
357
+ )
358
+ const attempt = Option.map(root, (url) => ({
359
+ url,
360
+ generation: current.generation,
361
+ ...Option.match(hooks.fileRequest(specifier), {
362
+ onNone: () => ({}),
363
+ onSome: (request) => ({ request }),
364
+ }),
365
+ }))
366
+ if (Option.isSome(attempt))
367
+ yield* Ref.update(
368
+ cached,
369
+ HashMap.set(attempt.value.url, attempt.value),
370
+ )
371
+ yield* Ref.update(
372
+ owners,
373
+ HashMap.set(token, {
374
+ active: Exit.isSuccess(exit) ? attempt : Option.none(),
375
+ pending: Exit.isFailure(exit) ? attempt : Option.none(),
376
+ }),
377
+ )
378
+ if (Exit.isSuccess(exit)) {
379
+ const entry = exit.value
380
+ const previous = HashMap.get(current.entries, entry.url)
381
+ const leases = Option.match(previous, {
382
+ onNone: () => HashSet.empty<symbol>(),
383
+ onSome: (entry) => entry.leases,
384
+ })
385
+ yield* Ref.set(state, {
386
+ ...current,
387
+ entries: HashMap.set(current.entries, entry.url, {
388
+ plugin: entry.plugin,
389
+ leases: HashSet.add(leases, token),
390
+ }),
391
+ })
392
+ }
393
+ return { token, exit: Exit.map(exit, (entry) => entry.namespace) }
394
+ }).pipe(
395
+ Effect.ensuring(Effect.sync(hooks.discard)),
396
+ Effect.ensuring(collect),
397
+ ),
398
+ ),
399
+ ({ token }) =>
400
+ serial(
401
+ Effect.gen(function* () {
402
+ yield* Ref.update(owners, HashMap.remove(token))
403
+ yield* Ref.update(state, (current) => ({
404
+ ...current,
405
+ entries: HashMap.filterMap(current.entries, (entry) => {
406
+ const leases = HashSet.remove(entry.leases, token)
407
+ return HashSet.size(leases) === 0
408
+ ? Result.fail(undefined)
409
+ : Result.succeed({ ...entry, leases })
410
+ }),
411
+ }))
412
+ }).pipe(Effect.ensuring(collect)),
413
+ ),
414
+ ).pipe(
415
+ Effect.flatMap(({ exit }) => exit),
416
+ preserveExit,
417
+ )
418
+
419
+ const importerScoped: Hmr["importerScoped"] = (specifier, options) =>
420
+ options === undefined
421
+ ? importWithLease(specifier)
422
+ : importWithLease(specifier).pipe(
423
+ Scope.provide(options.observationScope),
424
+ )
425
+
132
426
  const importer = (specifier: string): Promise<unknown> =>
133
- run(
427
+ // A closed FiberSet interrupts new work before checkOpen can execute.
428
+ Ref.getUnsafe(closed)
429
+ ? Promise.reject(closedError())
430
+ : run(
431
+ importerScoped(specifier).pipe(
432
+ Effect.provideService(Scope.Scope, imports),
433
+ ),
434
+ )
435
+
436
+ const unsupportedModules = Ref.get(state).pipe(
437
+ Effect.map(({ generation, entries }) =>
438
+ hooks.unsupported(
439
+ generation,
440
+ Array.fromIterable(HashMap.keys(entries)),
441
+ ),
442
+ ),
443
+ )
444
+
445
+ const reloadChanged = (dirty?: HashSet.HashSet<string>) =>
446
+ serial(
134
447
  Effect.gen(function* () {
135
448
  yield* checkOpen
136
- hooks.start(generation)
137
- const entry = yield* load(specifier)
138
- entries = HashMap.set(entries, entry.url, entry.plugin)
139
- return entry.namespace
140
- }).pipe(Effect.ensuring(Effect.sync(hooks.discard)), serial),
141
- )
142
- const reload = serial(
143
- Effect.gen(function* () {
144
- yield* checkOpen
145
- if (HashSet.size(generation.unsupported) > 0)
146
- return {
147
- _tag: "Skipped" as const,
148
- modules: Arr.fromIterable(generation.unsupported),
149
- reason: "restart-required" as const,
449
+ const fullRetry = yield* Ref.get(retryAll)
450
+ const force = dirty === undefined || fullRetry
451
+ const pendingDirty = yield* Ref.get(retryDirty)
452
+ const changedFiles =
453
+ dirty === undefined
454
+ ? pendingDirty
455
+ : HashSet.union(pendingDirty, dirty)
456
+ yield* Ref.set(retryDirty, changedFiles)
457
+ yield* Ref.set(retryAll, force)
458
+ const changed = yield* changedResolutions
459
+ if (changed.length > 0)
460
+ return WatchResult.Skipped({
461
+ reason: "restart-required",
462
+ modules: changed,
463
+ })
464
+ const { entries } = yield* Ref.get(state)
465
+ const unsupported = yield* unsupportedModules
466
+ if (HashSet.size(unsupported) > 0)
467
+ return WatchResult.Skipped({
468
+ modules: Array.fromIterable(unsupported),
469
+ reason: "restart-required",
470
+ })
471
+ const owned = yield* Ref.get(owners)
472
+ const attempts = Array.flatMap(HashMap.toValues(owned), (owner) =>
473
+ Array.getSomes([owner.active, owner.pending]),
474
+ )
475
+ const active = Array.getSomes(
476
+ Array.map(HashMap.toValues(owned), (owner) => owner.active),
477
+ )
478
+ let selected = entries
479
+ let invalid = HashSet.empty<string>()
480
+ if (!force) {
481
+ let affected = changedFiles
482
+ // Plugin identity groups and importer closure can expand each other.
483
+ // Reach a fixed point before allocating any candidate instance.
484
+ while (true) {
485
+ invalid = hooks.invalidated(attempts, affected)
486
+ const plugins = HashSet.fromIterable(
487
+ Array.filterMap(Array.fromIterable(entries), ([url, entry]) =>
488
+ HashSet.has(affected, url) ||
489
+ Array.some(Array.fromIterable(entry.leases), (token) =>
490
+ Option.exists(HashMap.get(owned, token), (owner) =>
491
+ Array.some(
492
+ Array.getSomes([owner.active, owner.pending]),
493
+ (attempt) => hooks.affected(attempt, invalid),
494
+ ),
495
+ ),
496
+ )
497
+ ? Result.succeed(entry.plugin)
498
+ : Result.fail(undefined),
499
+ ),
500
+ )
501
+ selected = HashMap.filter(entries, (entry) =>
502
+ HashSet.has(plugins, entry.plugin),
503
+ )
504
+ const expanded = HashSet.union(
505
+ affected,
506
+ HashSet.fromIterable(HashMap.keys(selected)),
507
+ )
508
+ if (HashSet.size(expanded) === HashSet.size(affected)) break
509
+ affected = expanded
510
+ }
150
511
  }
151
- // Advance even without entries so retries bypass cached failed imports.
152
- const candidate = hooks.prepare()
153
- hooks.start(candidate, true)
154
- return yield* Effect.gen(function* () {
155
- let next = HashMap.empty<string, Plugin.Plugin>()
156
- let pairs: ReadonlyArray<readonly [Plugin.Plugin, Plugin.Plugin]> = []
157
- for (const [url, previous] of entries) {
158
- const entry = yield* load(url).pipe(
159
- Effect.catchIf(
160
- (error) =>
161
- error.stage === "import" && isMissingEntry(url, error.cause),
162
- () => Effect.undefined,
512
+ // Advance even without entries so retries bypass cached failed imports.
513
+ const candidate = force
514
+ ? hooks.prepare()
515
+ : hooks.prepare(active, invalid)
516
+
517
+ hooks.start(candidate, true)
518
+ return yield* Effect.gen(function* () {
519
+ const loaded = yield* Effect.forEach(selected, ([url, previous]) =>
520
+ Ref.update(
521
+ owners,
522
+ HashMap.map((owner, token) =>
523
+ HashSet.has(previous.leases, token)
524
+ ? {
525
+ ...owner,
526
+ pending: Option.map(owner.active, (attempt) => ({
527
+ ...attempt,
528
+ url,
529
+ generation: candidate,
530
+ })),
531
+ }
532
+ : owner,
533
+ ),
534
+ ).pipe(
535
+ Effect.andThen(load(url)),
536
+ Effect.map((entry) =>
537
+ Option.some({
538
+ url,
539
+ previous: previous.plugin,
540
+ leases: previous.leases,
541
+ plugin: entry.plugin,
542
+ }),
543
+ ),
544
+ Effect.catchIf(
545
+ (error) =>
546
+ error.stage === "import" &&
547
+ isMissingEntry(url, error.cause),
548
+ () => Effect.succeedNone,
549
+ ),
163
550
  ),
164
551
  )
165
- if (entry === undefined) continue
166
- next = HashMap.set(next, url, entry.plugin)
167
- pairs = Arr.append(pairs, [previous, entry.plugin])
168
- }
169
- yield* Graft.replace(pairs).pipe(
170
- Effect.provideService(Graft.Graft, graft),
552
+ const candidates = Array.getSomes(loaded)
553
+ const alternatives = Array.reduce(
554
+ candidates,
555
+ HashMap.empty<Plugin.Plugin, HashSet.HashSet<Plugin.Plugin>>(),
556
+ (groups, { previous, plugin }) =>
557
+ HashMap.set(
558
+ groups,
559
+ previous,
560
+ HashSet.add(
561
+ Option.getOrElse(
562
+ HashMap.get(groups, previous),
563
+ HashSet.empty<Plugin.Plugin>,
564
+ ),
565
+ plugin,
566
+ ),
567
+ ),
568
+ )
569
+ const ambiguous = Array.filter(candidates, ({ previous }) =>
570
+ Option.exists(
571
+ HashMap.get(alternatives, previous),
572
+ (plugins) => HashSet.size(plugins) > 1,
573
+ ),
574
+ )
575
+ if (ambiguous.length > 0)
576
+ return WatchResult.Skipped({
577
+ reason: "ambiguous-exports",
578
+ modules: Array.map(ambiguous, ({ url }) => url),
579
+ })
580
+ const replacements = HashMap.fromIterable(
581
+ Array.map(
582
+ candidates,
583
+ ({ url, plugin, leases }) => [url, { plugin, leases }] as const,
584
+ ),
585
+ )
586
+ const next = HashMap.union(
587
+ HashMap.filter(entries, (_, url) => !HashMap.has(selected, url)),
588
+ replacements,
589
+ )
590
+
591
+ const pairs = Array.map(
592
+ candidates,
593
+ ({ previous, plugin }) => [previous, plugin] as const,
594
+ )
595
+ const changed = yield* changedResolutions
596
+ if (changed.length > 0)
597
+ return WatchResult.Skipped({
598
+ reason: "restart-required",
599
+ modules: changed,
600
+ })
601
+ // A reused instance can acquire a native loading capability while the
602
+ // candidate imports. Observe it without throwing into concurrent old code.
603
+ const unsafe = hooks.unsupported(
604
+ candidate,
605
+ Array.fromIterable(HashMap.keys(next)),
606
+ )
607
+ if (HashSet.size(unsafe) > 0)
608
+ return WatchResult.Skipped({
609
+ reason: "restart-required",
610
+ modules: Array.fromIterable(unsafe),
611
+ })
612
+
613
+ const replacement = yield* Graft.replace(pairs).pipe(
614
+ Effect.provideService(Graft.Graft, graft),
615
+ Effect.result,
616
+ )
617
+ if (Result.isFailure(replacement))
618
+ return WatchResult.Skipped({
619
+ reason: "identity-conflict",
620
+ modules: Array.filterMap(candidates, ({ url }, index) =>
621
+ Array.contains(replacement.failure.indices, index)
622
+ ? Result.succeed(url)
623
+ : Result.fail(undefined),
624
+ ),
625
+ })
626
+ yield* Ref.update(owners, (owners) =>
627
+ Array.reduce(candidates, owners, (owners, { url, leases }) =>
628
+ HashMap.map(owners, (owner, token) =>
629
+ HashSet.has(leases, token)
630
+ ? {
631
+ active: Option.map(owner.pending, (attempt) => ({
632
+ ...attempt,
633
+ url,
634
+ generation: candidate,
635
+ })),
636
+ pending: Option.none(),
637
+ }
638
+ : owner,
639
+ ),
640
+ ),
641
+ )
642
+ yield* Ref.set(state, { entries: next, generation: candidate })
643
+ yield* Ref.set(
644
+ cached,
645
+ HashMap.map(next, (_, url) => ({ url, generation: candidate })),
646
+ )
647
+ yield* Ref.set(retryDirty, HashSet.empty())
648
+ yield* Ref.set(retryAll, false)
649
+ return WatchResult.Reloaded({
650
+ entries: Array.map(candidates, (candidate) => candidate.url),
651
+ })
652
+ }).pipe(
653
+ Effect.catchIf(
654
+ (
655
+ error,
656
+ ): error is HmrError & { readonly cause: RestartRequired } =>
657
+ Schema.is(RestartRequired)(error.cause),
658
+ (error) =>
659
+ Effect.succeed(
660
+ WatchResult.Skipped({
661
+ modules: Array.fromIterable(
662
+ HashSet.add(
663
+ hooks.unsupported(candidate),
664
+ error.cause.url,
665
+ ),
666
+ ),
667
+ reason: "restart-required",
668
+ }),
669
+ ),
670
+ ),
671
+ Effect.ensuring(Effect.sync(hooks.discard)),
672
+ Effect.ensuring(collect),
171
673
  )
172
- entries = next
173
- generation = candidate
174
- return {
175
- _tag: "Reloaded" as const,
176
- entries: Arr.fromIterable(HashMap.keys(entries)),
177
- }
178
- }).pipe(
179
- Effect.catchIf(
180
- (error) => Schema.is(RestartRequired)(error.cause),
181
- () =>
182
- Effect.succeed({
183
- _tag: "Skipped" as const,
184
- modules: Arr.fromIterable(candidate.unsupported),
185
- reason: "restart-required" as const,
186
- }),
187
- ),
188
- Effect.ensuring(Effect.sync(hooks.discard)),
189
- )
190
- }),
191
- )
674
+ }),
675
+ )
192
676
  return {
193
677
  importer,
194
- reload,
678
+ importerScoped,
679
+ reload: reloadChanged(),
195
680
  watch: (paths, watchOptions) =>
196
- Stream.unwrap(
197
- Effect.map(FileSystem.FileSystem, (fs) =>
198
- Stream.mergeAll(
199
- Arr.map(paths, (path) => fs.watch(path, watchOptions)),
200
- { concurrency: "unbounded" },
681
+ paths.length === 0
682
+ ? Stream.empty
683
+ : changes(
684
+ (use) =>
685
+ Ref.get(owners).pipe(
686
+ Effect.map((owners) =>
687
+ hooks.files([
688
+ ...Array.getSomes(
689
+ Array.map(
690
+ HashMap.toValues(owners),
691
+ (owner) => owner.active,
692
+ ),
693
+ ),
694
+ ...Array.getSomes(
695
+ Array.map(
696
+ HashMap.toValues(owners),
697
+ (owner) => owner.pending,
698
+ ),
699
+ ),
700
+ ]),
701
+ ),
702
+ Effect.flatMap(use),
703
+ // Only snapshot and validation callbacks run under the lock.
704
+ // File reads operate on the retained immutable snapshot outside it.
705
+ semaphore.withPermits(1),
706
+ ),
707
+ paths,
708
+ watchOptions,
201
709
  ).pipe(
202
- Stream.debounce(watchOptions?.debounce ?? "50 millis"),
203
- Stream.mapEffect(() =>
204
- Effect.map(Effect.exit(reload), (exit): WatchResult =>
205
- Exit.isSuccess(exit)
206
- ? exit.value
207
- : { _tag: "Failed", cause: exit.cause },
710
+ Stream.mapEffect((dirty) =>
711
+ Effect.map(
712
+ Effect.exit(reloadChanged(dirty)),
713
+ (exit): WatchResult =>
714
+ Exit.isSuccess(exit)
715
+ ? exit.value
716
+ : WatchResult.Failed({ cause: exit.cause }),
717
+ ),
718
+ ),
719
+ // Native caches may hide both dependency edges and the first save.
720
+ // Report known restart requirements independently of filesystem events.
721
+ Stream.merge(
722
+ Stream.tick("1 second").pipe(
723
+ Stream.mapEffect(() =>
724
+ unsupportedModules.pipe(semaphore.withPermits(1)),
725
+ ),
726
+ Stream.changes,
727
+ Stream.filter((modules) => HashSet.size(modules) > 0),
728
+ Stream.map((modules) =>
729
+ WatchResult.Skipped({
730
+ reason: "restart-required",
731
+ modules: Array.fromIterable(modules),
732
+ }),
733
+ ),
208
734
  ),
209
735
  ),
210
736
  ),
211
- ),
212
- ),
213
737
  }
214
738
  })