@graftland/hmr 0.2.0-beta.0 → 0.2.0-beta.2

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
@@ -82,6 +82,8 @@ export interface ImportOptions {
82
82
  export interface Hmr {
83
83
  /** Keeps imported entries registered until this HMR session closes. */
84
84
  readonly importer: (specifier: string) => Promise<unknown>
85
+ /** Uses the application context and Node cache, without watching or replacing the module. */
86
+ readonly importModule: (specifier: string) => Effect.Effect<unknown, HmrError>
85
87
  /** Registers an observation lease in observationScope, or the caller's scope when omitted. */
86
88
  readonly importerScoped: (
87
89
  specifier: string,
@@ -90,8 +92,8 @@ export interface Hmr {
90
92
  /** Imports every entry before replacing anything. Activation failures belong to Graft. */
91
93
  readonly reload: Effect.Effect<ReloadResult, HmrError>
92
94
  /**
93
- * Consuming this stream installs watchers and checks loaded module contents once
94
- * per second to recover missed events. Known native loading limitations emit
95
+ * Consuming this stream installs graph-driven directory watchers, polls stat once
96
+ * per second, and audits contents every ten seconds to recover missed events. Known native loading limitations emit
95
97
  * Skipped even without a file event. The consumer owns the stream's lifetimes.
96
98
  */
97
99
  readonly watch: (
@@ -169,6 +171,32 @@ export const make = (
169
171
  Ref.set(closed, true).pipe(Effect.andThen(Effect.sync(hooks.close))),
170
172
  ),
171
173
  )
174
+ // Node can return a cached entry after its last observation lease closes.
175
+ // Keep current entry attempts without polling or reloading unleased entries.
176
+ // The hooks also retain cached instance facts for older resolution views.
177
+ const cached = yield* Ref.make(HashMap.empty<string, Attempt>())
178
+
179
+ const retryDirty = yield* Ref.make(HashSet.empty<string>())
180
+ const retryAll = yield* Ref.make(false)
181
+ const observations = Ref.get(owners).pipe(
182
+ Effect.map((owners) =>
183
+ Array.flatMap(HashMap.toValues(owners), (owner) =>
184
+ Array.getSomes([owner.active, owner.pending]),
185
+ ),
186
+ ),
187
+ )
188
+ const collect = Effect.gen(function* () {
189
+ const attempts = yield* observations
190
+ const currentCache = yield* Ref.get(cached)
191
+ hooks.collect([...attempts, ...HashMap.toValues(currentCache)])
192
+ const files = hooks.files(attempts)
193
+ yield* Ref.update(
194
+ retryDirty,
195
+ HashSet.filter((url) => HashMap.has(files, url)),
196
+ )
197
+ if (attempts.length === 0) yield* Ref.set(retryAll, false)
198
+ })
199
+
172
200
  const state = yield* Ref.make({
173
201
  generation: hooks.prepare(),
174
202
  entries: HashMap.empty<
@@ -223,21 +251,62 @@ export const make = (
223
251
  return Option.some(request)
224
252
  })
225
253
 
226
- const changedResolutions = Ref.get(owners).pipe(
227
- Effect.flatMap((owners) =>
254
+ const changedResolutions = Effect.gen(function* () {
255
+ const current = yield* Ref.get(owners)
256
+ const attempts = hooks.resolutions(
257
+ Array.flatMap(HashMap.toValues(current), (owner) =>
258
+ Array.getSomes([owner.active, owner.pending]),
259
+ ),
260
+ )
261
+ const requests = HashSet.fromIterable(
262
+ Array.filterMap(attempts, (attempt) =>
263
+ attempt.request === undefined
264
+ ? Result.fail(undefined)
265
+ : Result.succeed(attempt.request),
266
+ ),
267
+ )
268
+ const resolveAll = (requests: HashSet.HashSet<string>) =>
228
269
  Effect.forEach(
229
- hooks.resolutions(
230
- Array.flatMap(HashMap.toValues(owners), (owner) =>
231
- Array.getSomes([owner.active, owner.pending]),
270
+ requests,
271
+ (request) =>
272
+ physicalTarget(request).pipe(
273
+ Effect.map((target) => [request, target] as const),
232
274
  ),
233
- ),
234
- changedResolution,
275
+ { concurrency: 8 },
276
+ ).pipe(Effect.map(HashMap.fromIterable))
277
+ const actual = yield* resolveAll(requests)
278
+ const missingTargets = HashSet.fromIterable(
279
+ Array.filterMap(attempts, (attempt) =>
280
+ attempt.request !== undefined &&
281
+ Option.getOrUndefined(HashMap.get(actual, attempt.request)) ===
282
+ undefined &&
283
+ !HashMap.has(actual, attempt.url)
284
+ ? Result.succeed(attempt.url)
285
+ : Result.fail(undefined),
235
286
  ),
236
- ),
237
- Effect.map((changes) =>
238
- Array.fromIterable(HashSet.fromIterable(Array.getSomes(changes))),
239
- ),
240
- )
287
+ )
288
+ const remaining = yield* resolveAll(missingTargets)
289
+ const targets = HashMap.union(actual, remaining)
290
+
291
+ return Array.fromIterable(
292
+ HashSet.fromIterable(
293
+ Array.filterMap(attempts, (attempt) => {
294
+ if (attempt.request === undefined) return Result.fail(undefined)
295
+ const actual = Option.getOrUndefined(
296
+ HashMap.get(targets, attempt.request),
297
+ )
298
+ const expected = pathToFileURL(fileURLToPath(attempt.url)).href
299
+ return actual === expected ||
300
+ (actual === undefined &&
301
+ Option.getOrUndefined(HashMap.get(targets, attempt.url)) ===
302
+ undefined)
303
+ ? Result.fail(undefined)
304
+ : Result.succeed(attempt.request)
305
+ }),
306
+ ),
307
+ )
308
+ })
309
+
241
310
  const load = Effect.fnUntraced(function* (specifier: string) {
242
311
  const namespace = yield* Effect.tryPromise({
243
312
  try: () => import(/* @vite-ignore */ hooks.request(specifier)),
@@ -296,6 +365,11 @@ export const make = (
296
365
  onSome: (request) => ({ request }),
297
366
  }),
298
367
  }))
368
+ if (Option.isSome(attempt))
369
+ yield* Ref.update(
370
+ cached,
371
+ HashMap.set(attempt.value.url, attempt.value),
372
+ )
299
373
  yield* Ref.update(
300
374
  owners,
301
375
  HashMap.set(token, {
@@ -319,7 +393,10 @@ export const make = (
319
393
  })
320
394
  }
321
395
  return { token, exit: Exit.map(exit, (entry) => entry.namespace) }
322
- }).pipe(Effect.ensuring(Effect.sync(hooks.discard))),
396
+ }).pipe(
397
+ Effect.ensuring(Effect.sync(hooks.discard)),
398
+ Effect.ensuring(collect),
399
+ ),
323
400
  ),
324
401
  ({ token }) =>
325
402
  serial(
@@ -334,13 +411,26 @@ export const make = (
334
411
  : Result.succeed({ ...entry, leases })
335
412
  }),
336
413
  }))
337
- }),
414
+ }).pipe(Effect.ensuring(collect)),
338
415
  ),
339
416
  ).pipe(
340
417
  Effect.flatMap(({ exit }) => exit),
341
418
  preserveExit,
342
419
  )
343
420
 
421
+ const importModule: Hmr["importModule"] = (specifier) =>
422
+ serial(
423
+ checkOpen.pipe(
424
+ Effect.andThen(
425
+ Effect.tryPromise({
426
+ try: () => import(/* @vite-ignore */ hooks.rawRequest(specifier)),
427
+ catch: (cause) =>
428
+ HmrError.make({ stage: "import", specifier, cause }),
429
+ }),
430
+ ),
431
+ ),
432
+ )
433
+
344
434
  const importerScoped: Hmr["importerScoped"] = (specifier, options) =>
345
435
  options === undefined
346
436
  ? importWithLease(specifier)
@@ -358,166 +448,265 @@ export const make = (
358
448
  ),
359
449
  )
360
450
 
361
- const unsupportedModules = Ref.get(state).pipe(
362
- Effect.map(({ generation, entries }) =>
363
- hooks.unsupported(
364
- generation,
365
- Array.fromIterable(HashMap.keys(entries)),
451
+ const unsupportedAttempts = (attempts: ReadonlyArray<Attempt>) =>
452
+ Array.reduce(attempts, HashSet.empty<string>(), (all, attempt) =>
453
+ HashSet.union(
454
+ all,
455
+ hooks.unsupported(attempt.generation, [attempt.url]),
456
+ ),
457
+ )
458
+ const unsupportedModules = Ref.get(owners).pipe(
459
+ Effect.map((owned) =>
460
+ unsupportedAttempts(
461
+ Array.getSomes(
462
+ Array.map(HashMap.toValues(owned), (owner) => owner.active),
463
+ ),
366
464
  ),
367
465
  ),
368
466
  )
369
467
 
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
- )
468
+ const reloadChanged = (dirty?: HashSet.HashSet<string>) =>
469
+ serial(
470
+ Effect.gen(function* () {
471
+ yield* checkOpen
472
+ const fullRetry = yield* Ref.get(retryAll)
473
+ const force = dirty === undefined || fullRetry
474
+ const pendingDirty = yield* Ref.get(retryDirty)
475
+ const changedFiles =
476
+ dirty === undefined
477
+ ? pendingDirty
478
+ : HashSet.union(pendingDirty, dirty)
479
+ yield* Ref.set(retryDirty, changedFiles)
480
+ yield* Ref.set(retryAll, force)
460
481
  const changed = yield* changedResolutions
461
482
  if (changed.length > 0)
462
483
  return WatchResult.Skipped({
463
484
  reason: "restart-required",
464
485
  modules: changed,
465
486
  })
466
- const replacement = yield* Graft.replace(pairs).pipe(
467
- Effect.provideService(Graft.Graft, graft),
468
- Effect.result,
469
- )
470
- if (Result.isFailure(replacement))
487
+ const { entries } = yield* Ref.get(state)
488
+ const unsupported = yield* unsupportedModules
489
+ if (HashSet.size(unsupported) > 0)
471
490
  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
- ),
491
+ modules: Array.fromIterable(unsupported),
492
+ reason: "restart-required",
478
493
  })
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
- ),
494
+ const owned = yield* Ref.get(owners)
495
+ const attempts = Array.flatMap(HashMap.toValues(owned), (owner) =>
496
+ Array.getSomes([owner.active, owner.pending]),
494
497
  )
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),
498
+ const active = Array.getSomes(
499
+ Array.map(HashMap.toValues(owned), (owner) => owner.active),
500
+ )
501
+ let selected = entries
502
+ let invalid = HashSet.empty<string>()
503
+ if (!force) {
504
+ let affected = changedFiles
505
+ // Plugin identity groups and importer closure can expand each other.
506
+ // Reach a fixed point before allocating any candidate instance.
507
+ while (true) {
508
+ invalid = hooks.invalidated(attempts, affected)
509
+ const plugins = HashSet.fromIterable(
510
+ Array.filterMap(Array.fromIterable(entries), ([url, entry]) =>
511
+ HashSet.has(affected, url) ||
512
+ Array.some(Array.fromIterable(entry.leases), (token) =>
513
+ Option.exists(HashMap.get(owned, token), (owner) =>
514
+ Array.some(
515
+ Array.getSomes([owner.active, owner.pending]),
516
+ (attempt) => hooks.affected(attempt, invalid),
517
+ ),
518
+ ),
519
+ )
520
+ ? Result.succeed(entry.plugin)
521
+ : Result.fail(undefined),
522
+ ),
523
+ )
524
+ selected = HashMap.filter(entries, (entry) =>
525
+ HashSet.has(plugins, entry.plugin),
526
+ )
527
+ const expanded = HashSet.union(
528
+ affected,
529
+ HashSet.fromIterable(HashMap.keys(selected)),
530
+ )
531
+ if (HashSet.size(expanded) === HashSet.size(affected)) break
532
+ affected = expanded
533
+ }
534
+ }
535
+ // Advance even without entries so retries bypass cached failed imports.
536
+ const candidate = force
537
+ ? hooks.prepare()
538
+ : hooks.prepare(active, invalid)
539
+
540
+ hooks.start(candidate, true)
541
+ return yield* Effect.gen(function* () {
542
+ const loaded = yield* Effect.forEach(selected, ([url, previous]) =>
543
+ Ref.update(
544
+ owners,
545
+ HashMap.map((owner, token) =>
546
+ HashSet.has(previous.leases, token)
547
+ ? {
548
+ ...owner,
549
+ pending: Option.map(owner.active, (attempt) => ({
550
+ ...attempt,
551
+ url,
552
+ generation: candidate,
553
+ })),
554
+ }
555
+ : owner,
556
+ ),
557
+ ).pipe(
558
+ Effect.andThen(load(url)),
559
+ Effect.map((entry) =>
560
+ Option.some({
561
+ url,
562
+ previous: previous.plugin,
563
+ leases: previous.leases,
564
+ plugin: entry.plugin,
565
+ }),
566
+ ),
567
+ Effect.catchIf(
568
+ (error) =>
569
+ error.stage === "import" &&
570
+ isMissingEntry(url, error.cause),
571
+ () => Effect.succeedNone,
572
+ ),
573
+ ),
574
+ )
575
+ const candidates = Array.getSomes(loaded)
576
+ const alternatives = Array.reduce(
577
+ candidates,
578
+ HashMap.empty<Plugin.Plugin, HashSet.HashSet<Plugin.Plugin>>(),
579
+ (groups, { previous, plugin }) =>
580
+ HashMap.set(
581
+ groups,
582
+ previous,
583
+ HashSet.add(
584
+ Option.getOrElse(
585
+ HashMap.get(groups, previous),
586
+ HashSet.empty<Plugin.Plugin>,
587
+ ),
588
+ plugin,
508
589
  ),
509
- reason: "restart-required",
510
- }),
590
+ ),
591
+ )
592
+ const ambiguous = Array.filter(candidates, ({ previous }) =>
593
+ Option.exists(
594
+ HashMap.get(alternatives, previous),
595
+ (plugins) => HashSet.size(plugins) > 1,
511
596
  ),
512
- ),
513
- Effect.ensuring(Effect.sync(hooks.discard)),
514
- )
515
- }),
516
- )
597
+ )
598
+ if (ambiguous.length > 0)
599
+ return WatchResult.Skipped({
600
+ reason: "ambiguous-exports",
601
+ modules: Array.map(ambiguous, ({ url }) => url),
602
+ })
603
+ const replacements = HashMap.fromIterable(
604
+ Array.map(
605
+ candidates,
606
+ ({ url, plugin, leases }) => [url, { plugin, leases }] as const,
607
+ ),
608
+ )
609
+ // A missing entry still owns its running plugin until the last lease closes.
610
+ const next = HashMap.union(entries, replacements)
611
+
612
+ const pairs = Array.map(
613
+ candidates,
614
+ ({ previous, plugin }) => [previous, plugin] as const,
615
+ )
616
+ const changed = yield* changedResolutions
617
+ if (changed.length > 0)
618
+ return WatchResult.Skipped({
619
+ reason: "restart-required",
620
+ modules: changed,
621
+ })
622
+ // A reused instance can acquire a native loading capability while the
623
+ // candidate imports. Observe it without throwing into concurrent old code.
624
+ const unsafe = unsupportedAttempts(
625
+ Array.map(active, (attempt) =>
626
+ HashMap.has(replacements, attempt.url)
627
+ ? { ...attempt, generation: candidate }
628
+ : attempt,
629
+ ),
630
+ )
631
+ if (HashSet.size(unsafe) > 0)
632
+ return WatchResult.Skipped({
633
+ reason: "restart-required",
634
+ modules: Array.fromIterable(unsafe),
635
+ })
636
+
637
+ const replacement = yield* Graft.replace(pairs).pipe(
638
+ Effect.provideService(Graft.Graft, graft),
639
+ Effect.result,
640
+ )
641
+ if (Result.isFailure(replacement))
642
+ return WatchResult.Skipped({
643
+ reason: "identity-conflict",
644
+ modules: Array.filterMap(candidates, ({ url }, index) =>
645
+ Array.contains(replacement.failure.indices, index)
646
+ ? Result.succeed(url)
647
+ : Result.fail(undefined),
648
+ ),
649
+ })
650
+ yield* Ref.update(owners, (owners) =>
651
+ Array.reduce(candidates, owners, (owners, { url, leases }) =>
652
+ HashMap.map(owners, (owner, token) =>
653
+ HashSet.has(leases, token)
654
+ ? {
655
+ active: Option.map(owner.pending, (attempt) => ({
656
+ ...attempt,
657
+ url,
658
+ generation: candidate,
659
+ })),
660
+ pending: Option.none(),
661
+ }
662
+ : owner,
663
+ ),
664
+ ),
665
+ )
666
+ yield* Ref.set(state, { entries: next, generation: candidate })
667
+ yield* Ref.update(cached, (cached) =>
668
+ HashMap.union(
669
+ HashMap.filter(cached, (_, url) => HashMap.has(next, url)),
670
+ HashMap.map(replacements, (_, url) => ({
671
+ url,
672
+ generation: candidate,
673
+ })),
674
+ ),
675
+ )
676
+ yield* Ref.set(retryDirty, HashSet.empty())
677
+ yield* Ref.set(retryAll, false)
678
+ return WatchResult.Reloaded({
679
+ entries: Array.map(candidates, (candidate) => candidate.url),
680
+ })
681
+ }).pipe(
682
+ Effect.catchIf(
683
+ (
684
+ error,
685
+ ): error is HmrError & { readonly cause: RestartRequired } =>
686
+ Schema.is(RestartRequired)(error.cause),
687
+ (error) =>
688
+ Effect.succeed(
689
+ WatchResult.Skipped({
690
+ modules: Array.fromIterable(
691
+ HashSet.add(
692
+ hooks.unsupported(candidate),
693
+ error.cause.url,
694
+ ),
695
+ ),
696
+ reason: "restart-required",
697
+ }),
698
+ ),
699
+ ),
700
+ Effect.ensuring(Effect.sync(hooks.discard)),
701
+ Effect.ensuring(collect),
702
+ )
703
+ }),
704
+ )
517
705
  return {
518
706
  importer,
519
707
  importerScoped,
520
- reload,
708
+ importModule,
709
+ reload: reloadChanged(),
521
710
  watch: (paths, watchOptions) =>
522
711
  paths.length === 0
523
712
  ? Stream.empty
@@ -541,19 +730,20 @@ export const make = (
541
730
  ]),
542
731
  ),
543
732
  Effect.flatMap(use),
544
- // Hold ownership through the reads, so release cannot leave
545
- // an in-flight snapshot reading a retired path.
733
+ // Only snapshot and validation callbacks run under the lock.
734
+ // File reads operate on the retained immutable snapshot outside it.
546
735
  semaphore.withPermits(1),
547
736
  ),
548
737
  paths,
549
738
  watchOptions,
550
739
  ).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 }),
740
+ Stream.mapEffect((dirty) =>
741
+ Effect.map(
742
+ Effect.exit(reloadChanged(dirty)),
743
+ (exit): WatchResult =>
744
+ Exit.isSuccess(exit)
745
+ ? exit.value
746
+ : WatchResult.Failed({ cause: exit.cause }),
557
747
  ),
558
748
  ),
559
749
  // Native caches may hide both dependency edges and the first save.