@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.
package/src/index.ts CHANGED
@@ -90,8 +90,8 @@ export interface Hmr {
90
90
  /** Imports every entry before replacing anything. Activation failures belong to Graft. */
91
91
  readonly reload: Effect.Effect<ReloadResult, HmrError>
92
92
  /**
93
- * Consuming this stream installs watchers and checks loaded module contents once
94
- * per second to recover missed events. Known native loading limitations emit
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
95
  * Skipped even without a file event. The consumer owns the stream's lifetimes.
96
96
  */
97
97
  readonly watch: (
@@ -169,6 +169,32 @@ export const make = (
169
169
  Ref.set(closed, true).pipe(Effect.andThen(Effect.sync(hooks.close))),
170
170
  ),
171
171
  )
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
+
172
198
  const state = yield* Ref.make({
173
199
  generation: hooks.prepare(),
174
200
  entries: HashMap.empty<
@@ -223,21 +249,62 @@ export const make = (
223
249
  return Option.some(request)
224
250
  })
225
251
 
226
- const changedResolutions = Ref.get(owners).pipe(
227
- Effect.flatMap((owners) =>
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>) =>
228
267
  Effect.forEach(
229
- hooks.resolutions(
230
- Array.flatMap(HashMap.toValues(owners), (owner) =>
231
- Array.getSomes([owner.active, owner.pending]),
268
+ requests,
269
+ (request) =>
270
+ physicalTarget(request).pipe(
271
+ Effect.map((target) => [request, target] as const),
232
272
  ),
233
- ),
234
- changedResolution,
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),
235
284
  ),
236
- ),
237
- Effect.map((changes) =>
238
- Array.fromIterable(HashSet.fromIterable(Array.getSomes(changes))),
239
- ),
240
- )
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
+
241
308
  const load = Effect.fnUntraced(function* (specifier: string) {
242
309
  const namespace = yield* Effect.tryPromise({
243
310
  try: () => import(/* @vite-ignore */ hooks.request(specifier)),
@@ -296,6 +363,11 @@ export const make = (
296
363
  onSome: (request) => ({ request }),
297
364
  }),
298
365
  }))
366
+ if (Option.isSome(attempt))
367
+ yield* Ref.update(
368
+ cached,
369
+ HashMap.set(attempt.value.url, attempt.value),
370
+ )
299
371
  yield* Ref.update(
300
372
  owners,
301
373
  HashMap.set(token, {
@@ -319,7 +391,10 @@ export const make = (
319
391
  })
320
392
  }
321
393
  return { token, exit: Exit.map(exit, (entry) => entry.namespace) }
322
- }).pipe(Effect.ensuring(Effect.sync(hooks.discard))),
394
+ }).pipe(
395
+ Effect.ensuring(Effect.sync(hooks.discard)),
396
+ Effect.ensuring(collect),
397
+ ),
323
398
  ),
324
399
  ({ token }) =>
325
400
  serial(
@@ -334,7 +409,7 @@ export const make = (
334
409
  : Result.succeed({ ...entry, leases })
335
410
  }),
336
411
  }))
337
- }),
412
+ }).pipe(Effect.ensuring(collect)),
338
413
  ),
339
414
  ).pipe(
340
415
  Effect.flatMap(({ exit }) => exit),
@@ -367,157 +442,241 @@ export const make = (
367
442
  ),
368
443
  )
369
444
 
370
- const reload = serial(
371
- Effect.gen(function* () {
372
- yield* checkOpen
373
- const changed = yield* changedResolutions
374
- if (changed.length > 0)
375
- return WatchResult.Skipped({
376
- reason: "restart-required",
377
- modules: changed,
378
- })
379
- const { entries } = yield* Ref.get(state)
380
- const unsupported = yield* unsupportedModules
381
- if (HashSet.size(unsupported) > 0)
382
- return WatchResult.Skipped({
383
- modules: Array.fromIterable(unsupported),
384
- reason: "restart-required",
385
- })
386
- // Advance even without entries so retries bypass cached failed imports.
387
- const candidate = hooks.prepare()
388
- hooks.start(candidate, true)
389
- return yield* Effect.gen(function* () {
390
- const loaded = yield* Effect.forEach(entries, ([url, previous]) =>
391
- Ref.update(
392
- owners,
393
- HashMap.map((owner, token) =>
394
- HashSet.has(previous.leases, token)
395
- ? {
396
- ...owner,
397
- pending: Option.map(owner.active, (attempt) => ({
398
- ...attempt,
399
- url,
400
- generation: candidate,
401
- })),
402
- }
403
- : owner,
404
- ),
405
- ).pipe(
406
- Effect.andThen(load(url)),
407
- Effect.map((entry) =>
408
- Option.some({
409
- url,
410
- previous: previous.plugin,
411
- leases: previous.leases,
412
- plugin: entry.plugin,
413
- }),
414
- ),
415
- Effect.catchIf(
416
- (error) =>
417
- error.stage === "import" && isMissingEntry(url, error.cause),
418
- () => Effect.succeedNone,
419
- ),
420
- ),
421
- )
422
- const candidates = Array.getSomes(loaded)
423
- const alternatives = Array.reduce(
424
- candidates,
425
- HashMap.empty<Plugin.Plugin, HashSet.HashSet<Plugin.Plugin>>(),
426
- (groups, { previous, plugin }) =>
427
- HashMap.set(
428
- groups,
429
- previous,
430
- HashSet.add(
431
- Option.getOrElse(
432
- HashMap.get(groups, previous),
433
- HashSet.empty<Plugin.Plugin>,
434
- ),
435
- plugin,
436
- ),
437
- ),
438
- )
439
- const ambiguous = Array.filter(candidates, ({ previous }) =>
440
- Option.exists(
441
- HashMap.get(alternatives, previous),
442
- (plugins) => HashSet.size(plugins) > 1,
443
- ),
444
- )
445
- if (ambiguous.length > 0)
446
- return WatchResult.Skipped({
447
- reason: "ambiguous-exports",
448
- modules: Array.map(ambiguous, ({ url }) => url),
449
- })
450
- const next = HashMap.fromIterable(
451
- Array.map(
452
- candidates,
453
- ({ url, plugin, leases }) => [url, { plugin, leases }] as const,
454
- ),
455
- )
456
- const pairs = Array.map(
457
- candidates,
458
- ({ previous, plugin }) => [previous, plugin] as const,
459
- )
445
+ const reloadChanged = (dirty?: HashSet.HashSet<string>) =>
446
+ serial(
447
+ Effect.gen(function* () {
448
+ yield* checkOpen
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)
460
458
  const changed = yield* changedResolutions
461
459
  if (changed.length > 0)
462
460
  return WatchResult.Skipped({
463
461
  reason: "restart-required",
464
462
  modules: changed,
465
463
  })
466
- const replacement = yield* Graft.replace(pairs).pipe(
467
- Effect.provideService(Graft.Graft, graft),
468
- Effect.result,
469
- )
470
- if (Result.isFailure(replacement))
464
+ const { entries } = yield* Ref.get(state)
465
+ const unsupported = yield* unsupportedModules
466
+ if (HashSet.size(unsupported) > 0)
471
467
  return WatchResult.Skipped({
472
- reason: "identity-conflict",
473
- modules: Array.filterMap(candidates, ({ url }, index) =>
474
- Array.contains(replacement.failure.indices, index)
475
- ? Result.succeed(url)
476
- : Result.fail(undefined),
477
- ),
468
+ modules: Array.fromIterable(unsupported),
469
+ reason: "restart-required",
478
470
  })
479
- yield* Ref.update(owners, (owners) =>
480
- Array.reduce(candidates, owners, (owners, { url, leases }) =>
481
- HashMap.map(owners, (owner, token) =>
482
- HashSet.has(leases, token)
483
- ? {
484
- active: Option.map(owner.pending, (attempt) => ({
485
- ...attempt,
486
- url,
487
- generation: candidate,
488
- })),
489
- pending: Option.none(),
490
- }
491
- : owner,
492
- ),
493
- ),
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),
494
477
  )
495
- yield* Ref.set(state, { entries: next, generation: candidate })
496
- return WatchResult.Reloaded({
497
- entries: Array.fromIterable(HashMap.keys(next)),
498
- })
499
- }).pipe(
500
- Effect.catchIf(
501
- (error): error is HmrError & { readonly cause: RestartRequired } =>
502
- Schema.is(RestartRequired)(error.cause),
503
- (error) =>
504
- Effect.succeed(
505
- WatchResult.Skipped({
506
- modules: Array.fromIterable(
507
- HashSet.add(hooks.unsupported(candidate), error.cause.url),
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
+ }
511
+ }
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
+ ),
550
+ ),
551
+ )
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,
508
566
  ),
509
- reason: "restart-required",
510
- }),
567
+ ),
568
+ )
569
+ const ambiguous = Array.filter(candidates, ({ previous }) =>
570
+ Option.exists(
571
+ HashMap.get(alternatives, previous),
572
+ (plugins) => HashSet.size(plugins) > 1,
511
573
  ),
512
- ),
513
- Effect.ensuring(Effect.sync(hooks.discard)),
514
- )
515
- }),
516
- )
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),
673
+ )
674
+ }),
675
+ )
517
676
  return {
518
677
  importer,
519
678
  importerScoped,
520
- reload,
679
+ reload: reloadChanged(),
521
680
  watch: (paths, watchOptions) =>
522
681
  paths.length === 0
523
682
  ? Stream.empty
@@ -541,19 +700,20 @@ export const make = (
541
700
  ]),
542
701
  ),
543
702
  Effect.flatMap(use),
544
- // Hold ownership through the reads, so release cannot leave
545
- // an in-flight snapshot reading a retired path.
703
+ // Only snapshot and validation callbacks run under the lock.
704
+ // File reads operate on the retained immutable snapshot outside it.
546
705
  semaphore.withPermits(1),
547
706
  ),
548
707
  paths,
549
708
  watchOptions,
550
709
  ).pipe(
551
- Stream.debounce(watchOptions?.debounce ?? "50 millis"),
552
- Stream.mapEffect(() =>
553
- Effect.map(Effect.exit(reload), (exit): WatchResult =>
554
- Exit.isSuccess(exit)
555
- ? exit.value
556
- : WatchResult.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 }),
557
717
  ),
558
718
  ),
559
719
  // Native caches may hide both dependency edges and the first save.