@remix-run/node-hmr 0.0.0 → 0.1.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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +306 -2
  3. package/dist/index.d.ts +128 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +107 -0
  6. package/dist/lib/browser-events.d.ts +99 -0
  7. package/dist/lib/browser-events.d.ts.map +1 -0
  8. package/dist/lib/browser-events.js +11 -0
  9. package/dist/lib/events.d.ts +29 -0
  10. package/dist/lib/events.d.ts.map +1 -0
  11. package/dist/lib/events.js +32 -0
  12. package/dist/lib/hmr-analysis.d.ts +17 -0
  13. package/dist/lib/hmr-analysis.d.ts.map +1 -0
  14. package/dist/lib/hmr-analysis.js +130 -0
  15. package/dist/lib/module-store.d.ts +27 -0
  16. package/dist/lib/module-store.d.ts.map +1 -0
  17. package/dist/lib/module-store.js +161 -0
  18. package/dist/lib/process-state.d.ts +3 -0
  19. package/dist/lib/process-state.d.ts.map +1 -0
  20. package/dist/lib/process-state.js +7 -0
  21. package/dist/lib/runner.d.ts +62 -0
  22. package/dist/lib/runner.d.ts.map +1 -0
  23. package/dist/lib/runner.js +1046 -0
  24. package/dist/lib/runtime-api.d.ts +7 -0
  25. package/dist/lib/runtime-api.d.ts.map +1 -0
  26. package/dist/lib/runtime-api.js +1 -0
  27. package/dist/lib/runtime.d.ts +46 -0
  28. package/dist/lib/runtime.d.ts.map +1 -0
  29. package/dist/lib/runtime.js +374 -0
  30. package/dist/register.d.ts +2 -0
  31. package/dist/register.d.ts.map +1 -0
  32. package/dist/register.js +317 -0
  33. package/dist/runtime.d.ts +26 -0
  34. package/dist/runtime.d.ts.map +1 -0
  35. package/dist/runtime.js +32 -0
  36. package/dist/runtime.node-hmr.d.ts +27 -0
  37. package/dist/runtime.node-hmr.d.ts.map +1 -0
  38. package/dist/runtime.node-hmr.js +33 -0
  39. package/dist/types.d.ts +36 -0
  40. package/package.json +55 -5
  41. package/src/index.ts +244 -0
  42. package/src/lib/browser-events.ts +123 -0
  43. package/src/lib/events.ts +61 -0
  44. package/src/lib/hmr-analysis.ts +178 -0
  45. package/src/lib/module-store.ts +228 -0
  46. package/src/lib/process-state.ts +9 -0
  47. package/src/lib/runner.ts +1427 -0
  48. package/src/lib/runtime-api.ts +9 -0
  49. package/src/lib/runtime.ts +534 -0
  50. package/src/register.ts +401 -0
  51. package/src/runtime.node-hmr.ts +40 -0
  52. package/src/runtime.ts +40 -0
  53. package/src/types.d.ts +36 -0
@@ -0,0 +1,9 @@
1
+ import type { BrowserHmrChannel } from './browser-events.ts'
2
+
3
+ export interface NodeHmrRuntimeApi {
4
+ createBrowserHmrChannel(): Promise<BrowserHmrChannel>
5
+ emitServerReady(): void
6
+ }
7
+
8
+ export const nodeHmrRuntimeUnavailableError =
9
+ 'The node-hmr/runtime API is only available when running inside node-hmr'
@@ -0,0 +1,534 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ import {
4
+ type BrowserHmrChannel,
5
+ type BrowserHmrEvent,
6
+ type BrowserHmrFileEvent,
7
+ type BrowserHmrFileEventHandler,
8
+ type BrowserHmrWatchedFileDelta,
9
+ sendHmrEventPayload,
10
+ } from './browser-events.ts'
11
+ import { emitServerHmrEvent, emitServerHmrUpdate } from './events.ts'
12
+ import { hasNodeHmrParentProcess } from './process-state.ts'
13
+
14
+ /**
15
+ * Hot module context available at `import.meta.hot`.
16
+ */
17
+ export interface ImportMetaHot {
18
+ /** Mutable state preserved for this module across accepted updates and passed to dispose handlers. */
19
+ readonly data: Record<string, unknown>
20
+ /** Accepts updates to this module, optionally receiving its newly evaluated namespace. */
21
+ accept(callback?: (module: HotModule) => HotCallbackResult): void
22
+ /** Accepts updates from one dependency, optionally receiving its newly evaluated namespace. */
23
+ accept(dep: string, callback?: (module: HotModule) => HotCallbackResult): void
24
+ /**
25
+ * Accepts updates from multiple dependencies. The callback array preserves `deps` order and
26
+ * contains the updated namespace only at the position of the dependency that changed.
27
+ */
28
+ accept(
29
+ deps: readonly string[],
30
+ callback?: (modules: Array<HotModule | undefined>) => HotCallbackResult,
31
+ ): void
32
+ /** Registers cleanup that runs before this module is re-evaluated or the runtime is disposed. */
33
+ dispose(callback: (data: Record<string, unknown>) => HotCallbackResult): void
34
+ /** Declines the current update and asks the runner to restart the child process. */
35
+ invalidate(message?: string): void
36
+ /** Registers a custom-event listener for API compatibility. Server modules receive no events. */
37
+ on(event: string, callback: (data: unknown) => void | Promise<void>): void
38
+ }
39
+
40
+ type HotModule = Readonly<Record<string, unknown>> & {
41
+ readonly [Symbol.toStringTag]: 'Module'
42
+ }
43
+
44
+ export interface RemixNodeHmrRuntime {
45
+ createBrowserHmrChannel(): Promise<BrowserHmrChannel>
46
+ createHotContext(url: string, resolveDependency?: (specifier: string) => string): ImportMetaHot
47
+ disposeAll(): Promise<void>
48
+ emitServerReady(): void
49
+ handleBrowserHmrFileEvents(requestId: number, events: readonly BrowserHmrFileEvent[]): void
50
+ reportAcceptedDependencies(url: string, acceptedDeps: string[]): void
51
+ update(url: string, timestamp: number, acceptedUrl?: string): Promise<void>
52
+ }
53
+
54
+ type RuntimeGlobal = typeof globalThis & {
55
+ __remixNodeHmr?: RemixNodeHmrRuntime
56
+ }
57
+
58
+ type HotCallbackResult = void | Promise<void>
59
+ type HotCallback = (module: HotModule) => HotCallbackResult
60
+ type HotDependencyCallbackFunction = (module: HotModule) => HotCallbackResult
61
+ type HotDependencyArrayUpdateCallbackFunction = (
62
+ modules: Array<HotModule | undefined>,
63
+ ) => HotCallbackResult
64
+ type HotDependencyUpdateCallback = (module: HotModule, acceptedUrl: string) => HotCallbackResult
65
+ type HotDependencyCallback = {
66
+ callback: HotDependencyUpdateCallback
67
+ deps: string[]
68
+ }
69
+ type DisposeCallback = (data: Record<string, unknown>) => HotCallbackResult
70
+ type HotUpdateResult = 'accepted' | 'invalidated' | 'restart-requested'
71
+ const browserHmrChannelRequestTimeoutMs = 5_000
72
+
73
+ class NodeHotContext implements ImportMetaHot {
74
+ readonly data: Record<string, unknown>
75
+ readonly url: string
76
+
77
+ #acceptCallbacks: Array<HotCallback> = []
78
+ #acceptDependencyCallbacks: Array<HotDependencyCallback> = []
79
+ #disposeCallbacks: Array<DisposeCallback> = []
80
+ #invalidated = false
81
+ #invalidationMessage: string | undefined
82
+ #isUpdating = false
83
+ #disposeSelf: () => Promise<void>
84
+ #disposeDependency: (url: string) => Promise<void>
85
+ #resolveDependency: (specifier: string) => string
86
+
87
+ constructor(
88
+ url: string,
89
+ data: Record<string, unknown>,
90
+ disposeSelf: () => Promise<void>,
91
+ disposeDependency: (url: string) => Promise<void>,
92
+ resolveDependency: (specifier: string) => string,
93
+ ) {
94
+ this.data = data
95
+ this.url = url
96
+ this.#disposeSelf = disposeSelf
97
+ this.#disposeDependency = disposeDependency
98
+ this.#resolveDependency = resolveDependency
99
+ }
100
+
101
+ accept(callback?: (module: HotModule) => HotCallbackResult): void
102
+ accept(dep: string, callback?: (module: HotModule) => HotCallbackResult): void
103
+ accept(
104
+ deps: readonly string[],
105
+ callback?: (modules: Array<HotModule | undefined>) => HotCallbackResult,
106
+ ): void
107
+ accept(
108
+ deps?: string | readonly string[] | HotCallback,
109
+ callback: HotDependencyCallbackFunction | HotDependencyArrayUpdateCallbackFunction = () => {},
110
+ ) {
111
+ if (typeof deps === 'string') {
112
+ let normalizedDeps = [this.#normalizeAcceptedDependency(deps)]
113
+ let dependencyCallback = callback as HotDependencyCallbackFunction
114
+ this.#acceptDependencyCallbacks.push({
115
+ callback(module) {
116
+ return dependencyCallback(module)
117
+ },
118
+ deps: normalizedDeps,
119
+ })
120
+ return
121
+ }
122
+
123
+ if (isDependencyArray(deps)) {
124
+ let normalizedDeps = deps.map((dep) => this.#normalizeAcceptedDependency(dep))
125
+ let dependencyCallback = callback as HotDependencyArrayUpdateCallbackFunction
126
+ this.#acceptDependencyCallbacks.push({
127
+ callback(module, acceptedUrl) {
128
+ return dependencyCallback(
129
+ normalizedDeps.map((dep) => (dep === acceptedUrl ? module : undefined)),
130
+ )
131
+ },
132
+ deps: normalizedDeps,
133
+ })
134
+ return
135
+ }
136
+
137
+ this.#acceptCallbacks.push(deps ?? (() => {}))
138
+ }
139
+
140
+ dispose(callback: DisposeCallback) {
141
+ this.#disposeCallbacks.push(callback)
142
+ }
143
+
144
+ invalidate(message?: string) {
145
+ this.#invalidated = true
146
+ this.#invalidationMessage = message
147
+ if (this.#isUpdating) {
148
+ if (message !== undefined) console.warn(message)
149
+ return
150
+ }
151
+ requestRestart(message)
152
+ }
153
+
154
+ on(_event: string, _callback: (data: unknown) => void | Promise<void>) {
155
+ void _event
156
+ void _callback
157
+ }
158
+
159
+ async disposeAll() {
160
+ for (let callback of this.#disposeCallbacks) {
161
+ await callback(this.data)
162
+ }
163
+ }
164
+
165
+ async update(timestamp: number, acceptedUrl: string): Promise<HotUpdateResult> {
166
+ this.#invalidated = false
167
+ this.#invalidationMessage = undefined
168
+
169
+ if (acceptedUrl !== this.url) {
170
+ return await this.updateDependency(timestamp, acceptedUrl)
171
+ }
172
+
173
+ if (this.#acceptCallbacks.length === 0) {
174
+ requestRestart(`No HMR accept handler found for ${this.url}`)
175
+ return 'restart-requested'
176
+ }
177
+
178
+ await this.#disposeSelf()
179
+
180
+ this.#isUpdating = true
181
+ try {
182
+ let updatedModule = await import(`${this.url}?t=${timestamp}`)
183
+ for (let callback of this.#acceptCallbacks) {
184
+ await callback(updatedModule)
185
+ }
186
+ } finally {
187
+ this.#isUpdating = false
188
+ }
189
+
190
+ return this.#invalidated ? 'invalidated' : 'accepted'
191
+ }
192
+
193
+ get invalidationMessage(): string | undefined {
194
+ return this.#invalidationMessage
195
+ }
196
+
197
+ async updateDependency(timestamp: number, acceptedUrl: string): Promise<HotUpdateResult> {
198
+ let callbacks = this.#acceptDependencyCallbacks.filter((callback) =>
199
+ callback.deps.includes(acceptedUrl),
200
+ )
201
+ if (callbacks.length === 0) {
202
+ requestRestart(`No HMR accept handler found for ${acceptedUrl} via ${this.url}`)
203
+ return 'restart-requested'
204
+ }
205
+
206
+ this.#isUpdating = true
207
+ try {
208
+ await this.#disposeDependency(acceptedUrl)
209
+ let updatedModule = await import(`${acceptedUrl}?t=${timestamp}`)
210
+ for (let { callback } of callbacks) {
211
+ await callback(updatedModule, acceptedUrl)
212
+ }
213
+ } finally {
214
+ this.#isUpdating = false
215
+ }
216
+
217
+ return this.#invalidated ? 'invalidated' : 'accepted'
218
+ }
219
+
220
+ #normalizeAcceptedDependency(dep: string): string {
221
+ let resolved = this.#resolveDependency(dep)
222
+ let url = new URL(resolved)
223
+ url.search = ''
224
+ url.hash = ''
225
+ return url.href
226
+ }
227
+ }
228
+
229
+ function isDependencyArray(
230
+ deps: string | readonly string[] | HotCallback | undefined,
231
+ ): deps is readonly string[] {
232
+ return Array.isArray(deps)
233
+ }
234
+
235
+ export function getNodeHmrRuntime(): RemixNodeHmrRuntime | undefined {
236
+ let runtimeGlobal = globalThis as RuntimeGlobal
237
+ return runtimeGlobal.__remixNodeHmr
238
+ }
239
+
240
+ /**
241
+ * Notifies the parent process that the child server is ready.
242
+ */
243
+ export function emitServerReady(): void {
244
+ getNodeHmrRuntime()?.emitServerReady()
245
+ }
246
+
247
+ export function installNodeHmrRuntime(
248
+ options: {
249
+ browserEventUrl?: string
250
+ } = {},
251
+ ): RemixNodeHmrRuntime {
252
+ let runtimeGlobal = globalThis as RuntimeGlobal
253
+ if (runtimeGlobal.__remixNodeHmr) return runtimeGlobal.__remixNodeHmr
254
+
255
+ let dataByUrl = new Map<string, Record<string, unknown>>()
256
+ let updateContextsByUrl = new Map<string, NodeHotContext>()
257
+ let latestContextsByUrl = new Map<string, NodeHotContext>()
258
+ let browserEventUrl = options.browserEventUrl
259
+ let browserHmrChannelId = 0
260
+ let browserHmrChannelRequestId = 0
261
+ let pendingBrowserHmrChannelRequests = new Map<
262
+ number,
263
+ {
264
+ resolve(url: string | undefined): void
265
+ timer: ReturnType<typeof setTimeout>
266
+ }
267
+ >()
268
+ let browserHmrChannels = new Map<
269
+ number,
270
+ {
271
+ handleFileEvents(events: readonly BrowserHmrFileEvent[]): Promise<readonly BrowserHmrEvent[]>
272
+ }
273
+ >()
274
+
275
+ process.on('message', (message: unknown) => {
276
+ if (!isBrowserHmrChannelMessage(message)) return
277
+
278
+ let request = pendingBrowserHmrChannelRequests.get(message.requestId)
279
+ if (request === undefined) return
280
+
281
+ clearTimeout(request.timer)
282
+ pendingBrowserHmrChannelRequests.delete(message.requestId)
283
+ request.resolve(message.url)
284
+ })
285
+
286
+ let runtime: RemixNodeHmrRuntime = {
287
+ async createBrowserHmrChannel() {
288
+ browserEventUrl ??= await requestBrowserHmrChannelUrl()
289
+ if (browserEventUrl === undefined) {
290
+ throw new Error('Browser HMR is disabled for this node-hmr runtime')
291
+ }
292
+
293
+ let id = browserHmrChannelId++
294
+ let watchedFiles = new Set<string>()
295
+ let handlers = new Set<BrowserHmrFileEventHandler>()
296
+ let closed = false
297
+ browserHmrChannels.set(id, {
298
+ async handleFileEvents(events) {
299
+ let eventGroups = await Promise.all([...handlers].map((handler) => handler(events)))
300
+ return eventGroups.flat()
301
+ },
302
+ })
303
+
304
+ function updateWatchedFiles(delta: BrowserHmrWatchedFileDelta): void {
305
+ if (closed) return
306
+
307
+ for (let file of delta.add) {
308
+ watchedFiles.add(file)
309
+ }
310
+ for (let file of delta.remove) {
311
+ watchedFiles.delete(file)
312
+ }
313
+ process.send?.({
314
+ id,
315
+ delta,
316
+ type: 'node-hmr:child:browser-hmr-watch-files-changed',
317
+ })
318
+ }
319
+
320
+ return {
321
+ close() {
322
+ if (closed) return
323
+ closed = true
324
+
325
+ browserHmrChannels.delete(id)
326
+ handlers.clear()
327
+ let remove = [...watchedFiles]
328
+ watchedFiles.clear()
329
+ process.send?.({
330
+ id,
331
+ delta: { add: [], remove },
332
+ type: 'node-hmr:child:browser-hmr-watch-files-changed',
333
+ })
334
+ },
335
+
336
+ onFileEvents(handler) {
337
+ if (closed) return () => {}
338
+
339
+ handlers.add(handler)
340
+ return () => {
341
+ handlers.delete(handler)
342
+ }
343
+ },
344
+
345
+ updateWatchedFiles,
346
+
347
+ url: browserEventUrl,
348
+ }
349
+ },
350
+
351
+ createHotContext(url, resolveDependency = (specifier) => new URL(specifier, url).href) {
352
+ let data = dataByUrl.get(url)
353
+ if (data === undefined) {
354
+ data = {}
355
+ dataByUrl.set(url, data)
356
+ }
357
+
358
+ let context = new NodeHotContext(
359
+ url,
360
+ data,
361
+ async () => {
362
+ await latestContextsByUrl.get(url)?.disposeAll()
363
+ },
364
+ async (dependencyUrl) => {
365
+ await latestContextsByUrl.get(dependencyUrl)?.disposeAll()
366
+ },
367
+ resolveDependency,
368
+ )
369
+ if (!updateContextsByUrl.has(url)) {
370
+ updateContextsByUrl.set(url, context)
371
+ }
372
+ latestContextsByUrl.set(url, context)
373
+ return context
374
+ },
375
+
376
+ emitServerReady() {
377
+ if (!hasNodeHmrParentProcess()) return
378
+
379
+ process.send?.({
380
+ type: 'node-hmr:child:server-ready',
381
+ })
382
+ },
383
+
384
+ handleBrowserHmrFileEvents(requestId, events) {
385
+ if (!hasNodeHmrParentProcess()) return
386
+
387
+ Promise.all(
388
+ [...browserHmrChannels.values()].map((browserHmrChannel) =>
389
+ browserHmrChannel.handleFileEvents(events),
390
+ ),
391
+ )
392
+ .then((eventGroups) => {
393
+ process.send?.({
394
+ events: eventGroups.flat(),
395
+ requestId,
396
+ type: 'node-hmr:child:browser-hmr-file-events-handled',
397
+ })
398
+ })
399
+ .catch((error: unknown) => {
400
+ process.send?.({
401
+ error: formatUnknownError(error),
402
+ events: [],
403
+ requestId,
404
+ type: 'node-hmr:child:browser-hmr-file-events-handled',
405
+ })
406
+ })
407
+ },
408
+
409
+ reportAcceptedDependencies(url, acceptedDeps) {
410
+ if (!hasNodeHmrParentProcess()) return
411
+
412
+ process.send?.({
413
+ type: 'node-hmr:child:accepted-deps-resolved',
414
+ url,
415
+ acceptedDeps,
416
+ })
417
+ },
418
+
419
+ async disposeAll() {
420
+ for (let context of latestContextsByUrl.values()) {
421
+ await context.disposeAll()
422
+ }
423
+
424
+ updateContextsByUrl.clear()
425
+ latestContextsByUrl.clear()
426
+ },
427
+
428
+ async update(url, timestamp, acceptedUrl = url) {
429
+ let context = updateContextsByUrl.get(url)
430
+ if (context === undefined) {
431
+ requestRestart(`No HMR context found for ${url}`)
432
+ return
433
+ }
434
+
435
+ try {
436
+ let updateResult = await context.update(timestamp, acceptedUrl)
437
+ if (updateResult === 'invalidated') {
438
+ process.send?.({
439
+ acceptedUrl,
440
+ message: context.invalidationMessage,
441
+ timestamp,
442
+ type: 'node-hmr:child:hot-module-invalidated',
443
+ url,
444
+ })
445
+ return
446
+ }
447
+ if (updateResult === 'restart-requested') return
448
+
449
+ emitServerHmrUpdate({
450
+ ...(acceptedUrl === url ? {} : { acceptedUrl }),
451
+ filePath: acceptedUrl.startsWith('file:') ? fileURLToPath(acceptedUrl) : acceptedUrl,
452
+ timestamp,
453
+ type: 'update',
454
+ url,
455
+ })
456
+ } catch (error) {
457
+ requestRestart(`Failed to hot update ${url}: ${formatUnknownError(error)}`)
458
+ }
459
+ },
460
+ }
461
+
462
+ runtimeGlobal.__remixNodeHmr = runtime
463
+ return runtime
464
+
465
+ function requestBrowserHmrChannelUrl(): Promise<string | undefined> {
466
+ if (!hasNodeHmrParentProcess()) return Promise.resolve(undefined)
467
+
468
+ let requestId = browserHmrChannelRequestId++
469
+ return new Promise((resolvePromise) => {
470
+ let timer = setTimeout(() => {
471
+ pendingBrowserHmrChannelRequests.delete(requestId)
472
+ resolvePromise(undefined)
473
+ }, browserHmrChannelRequestTimeoutMs)
474
+
475
+ pendingBrowserHmrChannelRequests.set(requestId, {
476
+ resolve: resolvePromise,
477
+ timer,
478
+ })
479
+
480
+ if (
481
+ !process.send?.({
482
+ requestId,
483
+ type: 'node-hmr:child:browser-hmr-channel-requested',
484
+ })
485
+ ) {
486
+ clearTimeout(timer)
487
+ pendingBrowserHmrChannelRequests.delete(requestId)
488
+ resolvePromise(undefined)
489
+ }
490
+ })
491
+ }
492
+ }
493
+
494
+ function isBrowserHmrChannelMessage(message: unknown): message is {
495
+ requestId: number
496
+ type: 'node-hmr:parent:browser-hmr-channel'
497
+ url?: string
498
+ } {
499
+ return (
500
+ typeof message === 'object' &&
501
+ message !== null &&
502
+ 'type' in message &&
503
+ message.type === 'node-hmr:parent:browser-hmr-channel' &&
504
+ 'requestId' in message &&
505
+ typeof message.requestId === 'number' &&
506
+ (!('url' in message) || typeof message.url === 'string')
507
+ )
508
+ }
509
+
510
+ function requestRestart(message?: string): void {
511
+ if (message !== undefined) {
512
+ console.warn(message)
513
+ }
514
+
515
+ emitServerHmrEvent({
516
+ reason: message,
517
+ timestamp: Date.now(),
518
+ type: 'restart',
519
+ })
520
+
521
+ if (process.send) {
522
+ process.send({
523
+ type: 'node-hmr:child:restart-requested',
524
+ message,
525
+ })
526
+ return
527
+ }
528
+
529
+ process.kill(process.pid, 'SIGTERM')
530
+ }
531
+
532
+ function formatUnknownError(error: unknown): string {
533
+ return error instanceof Error ? error.message : String(error)
534
+ }