@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.
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
- }
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>()
43
61
 
44
- export type WatchResult =
45
- | ReloadResult
46
- | {
47
- readonly _tag: "Failed"
48
- readonly cause: Cause.Cause<HmrError>
49
- }
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 watchers and checks loaded module contents once
94
+ * per second 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,39 +114,129 @@ 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
+ const state = yield* Ref.make({
173
+ generation: hooks.prepare(),
174
+ entries: HashMap.empty<
175
+ string,
176
+ {
177
+ readonly plugin: Plugin.Plugin
178
+ readonly leases: HashSet.HashSet<symbol>
179
+ }
180
+ >(),
181
+ })
182
+ // Stop Promise-backed operations before releasing their leases. Otherwise
183
+ // a lease finalizer could wait on an import before its owner interrupts it.
184
+ const imports = yield* Scope.fork(session)
106
185
  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,
186
+ const closedError = () =>
187
+ HmrError.make({
188
+ stage: "closed",
189
+ specifier: "",
190
+ cause: "HMR scope is closed",
191
+ })
192
+ const checkOpen = Ref.get(closed).pipe(
193
+ Effect.flatMap((closed) =>
194
+ closed ? Effect.fail(closedError()) : Effect.void,
195
+ ),
196
+ )
197
+ const physicalTarget = (request: string) =>
198
+ Effect.tryPromise({
199
+ try: () => realpath(fileURLToPath(request)),
200
+ catch: (cause) =>
201
+ HmrError.make({ stage: "import", specifier: request, cause }),
202
+ }).pipe(
203
+ Effect.map((path) => pathToFileURL(path).href),
204
+ Effect.catchIf(
205
+ (error) => isMissingEntry(request, error.cause),
206
+ () => Effect.undefined,
207
+ ),
208
+ )
209
+ const changedResolution = Effect.fnUntraced(function* (
210
+ attempt: Pick<Attempt, "url" | "request">,
211
+ ) {
212
+ if (attempt.request === undefined) return Option.none<string>()
213
+ const request = attempt.request
214
+ const expected = pathToFileURL(fileURLToPath(attempt.url)).href
215
+ const actual = yield* physicalTarget(request)
216
+ if (actual === expected) return Option.none<string>()
217
+ // If both paths disappeared, this is ordinary entry deletion. A
218
+ // missing link with a surviving old target must never reload that target.
219
+ if (actual === undefined) {
220
+ const remaining = yield* physicalTarget(attempt.url)
221
+ if (remaining === undefined) return Option.none<string>()
222
+ }
223
+ return Option.some(request)
224
+ })
225
+
226
+ const changedResolutions = Ref.get(owners).pipe(
227
+ Effect.flatMap((owners) =>
228
+ Effect.forEach(
229
+ hooks.resolutions(
230
+ Array.flatMap(HashMap.toValues(owners), (owner) =>
231
+ Array.getSomes([owner.active, owner.pending]),
232
+ ),
233
+ ),
234
+ changedResolution,
235
+ ),
236
+ ),
237
+ Effect.map((changes) =>
238
+ Array.fromIterable(HashSet.fromIterable(Array.getSomes(changes))),
239
+ ),
117
240
  )
118
241
  const load = Effect.fnUntraced(function* (specifier: string) {
119
242
  const namespace = yield* Effect.tryPromise({
@@ -127,63 +250,265 @@ export const make = (
127
250
  specifier,
128
251
  cause: "Expected a default or named plugin export",
129
252
  })
130
- return { namespace, plugin: plugin.value, url: hooks.resolved()! }
253
+ const url = yield* Effect.fromOption(hooks.resolved()).pipe(
254
+ Effect.mapError((cause) =>
255
+ HmrError.make({ stage: "import", specifier, cause }),
256
+ ),
257
+ )
258
+ const request = hooks.fileRequest(specifier)
259
+ const attempt = {
260
+ url,
261
+ ...Option.match(request, {
262
+ onNone: () => ({}),
263
+ onSome: (request) => ({ request }),
264
+ }),
265
+ }
266
+ const changed = yield* changedResolution(attempt)
267
+ if (Option.isSome(changed))
268
+ return yield* HmrError.make({
269
+ stage: "import",
270
+ specifier,
271
+ cause: RestartRequired.make({ url: changed.value }),
272
+ })
273
+ return { namespace, plugin: plugin.value, url }
131
274
  })
132
- const importer = (specifier: string): Promise<unknown> =>
133
- run(
134
- Effect.gen(function* () {
135
- 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),
275
+ const importWithLease = (specifier: string) =>
276
+ Effect.acquireRelease(
277
+ serial(
278
+ Effect.gen(function* () {
279
+ yield* checkOpen
280
+ const current = yield* Ref.get(state)
281
+ hooks.start(current.generation)
282
+ // Acquire an owner for either Exit. Rethrow only after its finalizer
283
+ // is registered, so failed imports remain available for scoped retry.
284
+ const exit = yield* Effect.exit(load(specifier))
285
+ const token: symbol = Symbol()
286
+ const root = Option.orElse(hooks.resolved(), () =>
287
+ String.startsWith("file:")(specifier)
288
+ ? Option.some(specifier)
289
+ : Option.none(),
290
+ )
291
+ const attempt = Option.map(root, (url) => ({
292
+ url,
293
+ generation: current.generation,
294
+ ...Option.match(hooks.fileRequest(specifier), {
295
+ onNone: () => ({}),
296
+ onSome: (request) => ({ request }),
297
+ }),
298
+ }))
299
+ yield* Ref.update(
300
+ owners,
301
+ HashMap.set(token, {
302
+ active: Exit.isSuccess(exit) ? attempt : Option.none(),
303
+ pending: Exit.isFailure(exit) ? attempt : Option.none(),
304
+ }),
305
+ )
306
+ if (Exit.isSuccess(exit)) {
307
+ const entry = exit.value
308
+ const previous = HashMap.get(current.entries, entry.url)
309
+ const leases = Option.match(previous, {
310
+ onNone: () => HashSet.empty<symbol>(),
311
+ onSome: (entry) => entry.leases,
312
+ })
313
+ yield* Ref.set(state, {
314
+ ...current,
315
+ entries: HashMap.set(current.entries, entry.url, {
316
+ plugin: entry.plugin,
317
+ leases: HashSet.add(leases, token),
318
+ }),
319
+ })
320
+ }
321
+ return { token, exit: Exit.map(exit, (entry) => entry.namespace) }
322
+ }).pipe(Effect.ensuring(Effect.sync(hooks.discard))),
323
+ ),
324
+ ({ token }) =>
325
+ serial(
326
+ Effect.gen(function* () {
327
+ yield* Ref.update(owners, HashMap.remove(token))
328
+ yield* Ref.update(state, (current) => ({
329
+ ...current,
330
+ entries: HashMap.filterMap(current.entries, (entry) => {
331
+ const leases = HashSet.remove(entry.leases, token)
332
+ return HashSet.size(leases) === 0
333
+ ? Result.fail(undefined)
334
+ : Result.succeed({ ...entry, leases })
335
+ }),
336
+ }))
337
+ }),
338
+ ),
339
+ ).pipe(
340
+ Effect.flatMap(({ exit }) => exit),
341
+ preserveExit,
141
342
  )
343
+
344
+ const importerScoped: Hmr["importerScoped"] = (specifier, options) =>
345
+ options === undefined
346
+ ? importWithLease(specifier)
347
+ : importWithLease(specifier).pipe(
348
+ Scope.provide(options.observationScope),
349
+ )
350
+
351
+ const importer = (specifier: string): Promise<unknown> =>
352
+ // A closed FiberSet interrupts new work before checkOpen can execute.
353
+ Ref.getUnsafe(closed)
354
+ ? Promise.reject(closedError())
355
+ : run(
356
+ importerScoped(specifier).pipe(
357
+ Effect.provideService(Scope.Scope, imports),
358
+ ),
359
+ )
360
+
361
+ const unsupportedModules = Ref.get(state).pipe(
362
+ Effect.map(({ generation, entries }) =>
363
+ hooks.unsupported(
364
+ generation,
365
+ Array.fromIterable(HashMap.keys(entries)),
366
+ ),
367
+ ),
368
+ )
369
+
142
370
  const reload = serial(
143
371
  Effect.gen(function* () {
144
372
  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,
150
- }
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
+ })
151
386
  // Advance even without entries so retries bypass cached failed imports.
152
387
  const candidate = hooks.prepare()
153
388
  hooks.start(candidate, true)
154
389
  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(
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
+ ),
159
415
  Effect.catchIf(
160
416
  (error) =>
161
417
  error.stage === "import" && isMissingEntry(url, error.cause),
162
- () => Effect.undefined,
418
+ () => Effect.succeedNone,
163
419
  ),
164
- )
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(
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
+ )
460
+ const changed = yield* changedResolutions
461
+ if (changed.length > 0)
462
+ return WatchResult.Skipped({
463
+ reason: "restart-required",
464
+ modules: changed,
465
+ })
466
+ const replacement = yield* Graft.replace(pairs).pipe(
170
467
  Effect.provideService(Graft.Graft, graft),
468
+ Effect.result,
171
469
  )
172
- entries = next
173
- generation = candidate
174
- return {
175
- _tag: "Reloaded" as const,
176
- entries: Arr.fromIterable(HashMap.keys(entries)),
177
- }
470
+ if (Result.isFailure(replacement))
471
+ 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
+ ),
478
+ })
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
+ )
495
+ yield* Ref.set(state, { entries: next, generation: candidate })
496
+ return WatchResult.Reloaded({
497
+ entries: Array.fromIterable(HashMap.keys(next)),
498
+ })
178
499
  }).pipe(
179
500
  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
- }),
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),
508
+ ),
509
+ reason: "restart-required",
510
+ }),
511
+ ),
187
512
  ),
188
513
  Effect.ensuring(Effect.sync(hooks.discard)),
189
514
  )
@@ -191,24 +516,63 @@ export const make = (
191
516
  )
192
517
  return {
193
518
  importer,
519
+ importerScoped,
194
520
  reload,
195
521
  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" },
522
+ paths.length === 0
523
+ ? Stream.empty
524
+ : changes(
525
+ (use) =>
526
+ Ref.get(owners).pipe(
527
+ Effect.map((owners) =>
528
+ hooks.files([
529
+ ...Array.getSomes(
530
+ Array.map(
531
+ HashMap.toValues(owners),
532
+ (owner) => owner.active,
533
+ ),
534
+ ),
535
+ ...Array.getSomes(
536
+ Array.map(
537
+ HashMap.toValues(owners),
538
+ (owner) => owner.pending,
539
+ ),
540
+ ),
541
+ ]),
542
+ ),
543
+ Effect.flatMap(use),
544
+ // Hold ownership through the reads, so release cannot leave
545
+ // an in-flight snapshot reading a retired path.
546
+ semaphore.withPermits(1),
547
+ ),
548
+ paths,
549
+ watchOptions,
201
550
  ).pipe(
202
551
  Stream.debounce(watchOptions?.debounce ?? "50 millis"),
203
552
  Stream.mapEffect(() =>
204
553
  Effect.map(Effect.exit(reload), (exit): WatchResult =>
205
554
  Exit.isSuccess(exit)
206
555
  ? exit.value
207
- : { _tag: "Failed", cause: exit.cause },
556
+ : WatchResult.Failed({ cause: exit.cause }),
557
+ ),
558
+ ),
559
+ // Native caches may hide both dependency edges and the first save.
560
+ // Report known restart requirements independently of filesystem events.
561
+ Stream.merge(
562
+ Stream.tick("1 second").pipe(
563
+ Stream.mapEffect(() =>
564
+ unsupportedModules.pipe(semaphore.withPermits(1)),
565
+ ),
566
+ Stream.changes,
567
+ Stream.filter((modules) => HashSet.size(modules) > 0),
568
+ Stream.map((modules) =>
569
+ WatchResult.Skipped({
570
+ reason: "restart-required",
571
+ modules: Array.fromIterable(modules),
572
+ }),
573
+ ),
208
574
  ),
209
575
  ),
210
576
  ),
211
- ),
212
- ),
213
577
  }
214
578
  })