@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/README.md +150 -2
- package/README.zh-CN.md +120 -0
- package/README.zh-TW.md +120 -0
- package/dist/index.d.ts +39 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +187 -45
- package/dist/index.js.map +1 -1
- package/dist/internal/changes.js +59 -0
- package/dist/internal/changes.js.map +1 -0
- package/dist/internal/modules.js +141 -28
- package/dist/internal/modules.js.map +1 -1
- package/package.json +7 -5
- package/src/index.ts +451 -87
- package/src/internal/changes.ts +199 -0
- package/src/internal/modules.ts +335 -42
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
|
|
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
|
-
|
|
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
|
|
21
|
-
import {
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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?:
|
|
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
|
-
|
|
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)
|
|
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) =>
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
418
|
+
() => Effect.succeedNone,
|
|
163
419
|
),
|
|
164
|
-
)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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)
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
: {
|
|
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
|
})
|