@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/README.md +195 -2
- package/README.zh-CN.md +137 -0
- package/README.zh-TW.md +137 -0
- package/dist/index.d.ts +39 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +250 -48
- package/dist/index.js.map +1 -1
- package/dist/internal/changes.js +92 -0
- package/dist/internal/changes.js.map +1 -0
- package/dist/internal/digest.js +6 -0
- package/dist/internal/digest.js.map +1 -0
- package/dist/internal/modules.js +237 -39
- package/dist/internal/modules.js.map +1 -1
- package/package.json +7 -5
- package/src/index.ts +632 -108
- package/src/internal/changes.ts +274 -0
- package/src/internal/digest.ts +5 -0
- package/src/internal/modules.ts +403 -57
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
/**
|
|
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 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?:
|
|
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
|
-
|
|
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
|
+
// 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
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
678
|
+
importerScoped,
|
|
679
|
+
reload: reloadChanged(),
|
|
195
680
|
watch: (paths, watchOptions) =>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
})
|