@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.
- package/LICENSE +21 -0
- package/README.md +306 -2
- package/dist/index.d.ts +128 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +107 -0
- package/dist/lib/browser-events.d.ts +99 -0
- package/dist/lib/browser-events.d.ts.map +1 -0
- package/dist/lib/browser-events.js +11 -0
- package/dist/lib/events.d.ts +29 -0
- package/dist/lib/events.d.ts.map +1 -0
- package/dist/lib/events.js +32 -0
- package/dist/lib/hmr-analysis.d.ts +17 -0
- package/dist/lib/hmr-analysis.d.ts.map +1 -0
- package/dist/lib/hmr-analysis.js +130 -0
- package/dist/lib/module-store.d.ts +27 -0
- package/dist/lib/module-store.d.ts.map +1 -0
- package/dist/lib/module-store.js +161 -0
- package/dist/lib/process-state.d.ts +3 -0
- package/dist/lib/process-state.d.ts.map +1 -0
- package/dist/lib/process-state.js +7 -0
- package/dist/lib/runner.d.ts +62 -0
- package/dist/lib/runner.d.ts.map +1 -0
- package/dist/lib/runner.js +1046 -0
- package/dist/lib/runtime-api.d.ts +7 -0
- package/dist/lib/runtime-api.d.ts.map +1 -0
- package/dist/lib/runtime-api.js +1 -0
- package/dist/lib/runtime.d.ts +46 -0
- package/dist/lib/runtime.d.ts.map +1 -0
- package/dist/lib/runtime.js +374 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +317 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +32 -0
- package/dist/runtime.node-hmr.d.ts +27 -0
- package/dist/runtime.node-hmr.d.ts.map +1 -0
- package/dist/runtime.node-hmr.js +33 -0
- package/dist/types.d.ts +36 -0
- package/package.json +55 -5
- package/src/index.ts +244 -0
- package/src/lib/browser-events.ts +123 -0
- package/src/lib/events.ts +61 -0
- package/src/lib/hmr-analysis.ts +178 -0
- package/src/lib/module-store.ts +228 -0
- package/src/lib/process-state.ts +9 -0
- package/src/lib/runner.ts +1427 -0
- package/src/lib/runtime-api.ts +9 -0
- package/src/lib/runtime.ts +534 -0
- package/src/register.ts +401 -0
- package/src/runtime.node-hmr.ts +40 -0
- package/src/runtime.ts +40 -0
- package/src/types.d.ts +36 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
|
|
4
|
+
import { createHmrSupervisor } from './lib/runner.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Options for running a Node.js entry module with HMR supervision.
|
|
8
|
+
*/
|
|
9
|
+
export interface RunOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Configures the parent-owned EventSource server used to coordinate browser HMR, or disables it
|
|
12
|
+
* with `false`. Enabled with default options when omitted or set to `true`.
|
|
13
|
+
*/
|
|
14
|
+
browserHmrChannel?: boolean | BrowserHmrChannelOptions
|
|
15
|
+
/** Working directory used to resolve the entry path and relative watch options. (`process.cwd()`) */
|
|
16
|
+
cwd?: string
|
|
17
|
+
/** Arguments passed to the entry module after the entry path. */
|
|
18
|
+
entryArgs?: readonly string[]
|
|
19
|
+
/** Complete environment for the child process. (`process.env`) */
|
|
20
|
+
env?: NodeJS.ProcessEnv
|
|
21
|
+
/** Node.js arguments passed before the entry path. */
|
|
22
|
+
nodeArgs?: readonly string[]
|
|
23
|
+
/** File watching options for the supervised process. */
|
|
24
|
+
watch?: NodeHmrWatchOptions
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Browser HMR event stream options hosted by the parent process.
|
|
29
|
+
*/
|
|
30
|
+
export interface BrowserHmrChannelOptions {
|
|
31
|
+
/** Hostname for the browser HMR event server. (`'127.0.0.1'`) */
|
|
32
|
+
host?: string
|
|
33
|
+
/** Port for the browser HMR event server. Uses an available ephemeral port when omitted. */
|
|
34
|
+
port?: number
|
|
35
|
+
/** URL pathname for the browser HMR event stream. (`'/hmr'`) */
|
|
36
|
+
pathname?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* File watching options for a Node HMR runner.
|
|
41
|
+
*/
|
|
42
|
+
export interface NodeHmrWatchOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Ignore matching glob patterns or file paths. Relative values are resolved
|
|
45
|
+
* from the runner's `cwd`.
|
|
46
|
+
*/
|
|
47
|
+
ignore?: readonly string[]
|
|
48
|
+
/**
|
|
49
|
+
* Use polling instead of native filesystem events. Defaults to `true` on
|
|
50
|
+
* Windows and `false` elsewhere.
|
|
51
|
+
*/
|
|
52
|
+
poll?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Polling interval in milliseconds when `poll` is enabled. Defaults to `100`.
|
|
55
|
+
*/
|
|
56
|
+
pollInterval?: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Handle returned by {@link run} for controlling the supervised process.
|
|
61
|
+
*/
|
|
62
|
+
export interface NodeHmrRunner {
|
|
63
|
+
/**
|
|
64
|
+
* Stops the runner and waits for the child process to exit.
|
|
65
|
+
*
|
|
66
|
+
* @returns A promise that resolves once the runner has stopped.
|
|
67
|
+
*/
|
|
68
|
+
close(): Promise<void>
|
|
69
|
+
/**
|
|
70
|
+
* Current server generation, incremented after every accepted hot update or process restart.
|
|
71
|
+
*/
|
|
72
|
+
readonly generation: number
|
|
73
|
+
/**
|
|
74
|
+
* Waits until the latest update or restart has settled and the current child process is ready.
|
|
75
|
+
*
|
|
76
|
+
* If the app uses `emitServerReady()`, restart readiness also waits for that signal.
|
|
77
|
+
*
|
|
78
|
+
* @returns A promise that resolves when the latest requested generation is ready.
|
|
79
|
+
*/
|
|
80
|
+
ready(): Promise<void>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type HmrReadyFetchRetryContext = {
|
|
84
|
+
/** Child process lifecycle generation that handled the fetch attempt. */
|
|
85
|
+
generation: number
|
|
86
|
+
/** Request passed to the wrapped fetch handler. */
|
|
87
|
+
request: Request
|
|
88
|
+
} & (
|
|
89
|
+
| {
|
|
90
|
+
/** Error thrown by the wrapped fetch handler. */
|
|
91
|
+
error: unknown
|
|
92
|
+
/** Response is absent when the wrapped fetch handler throws. */
|
|
93
|
+
response?: never
|
|
94
|
+
}
|
|
95
|
+
| {
|
|
96
|
+
/** Error is absent when the wrapped fetch handler returns a response. */
|
|
97
|
+
error?: never
|
|
98
|
+
/** Response returned by the wrapped fetch handler. */
|
|
99
|
+
response: Response
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Options for {@link createHmrReadyFetch}.
|
|
105
|
+
*/
|
|
106
|
+
export interface HmrReadyFetchOptions {
|
|
107
|
+
/**
|
|
108
|
+
* Determines whether a response or thrown error should be retried if the
|
|
109
|
+
* runner moves to a new generation while the request is in flight. Defaults
|
|
110
|
+
* to retrying `GET` and `HEAD` requests when the fetch throws or returns
|
|
111
|
+
* `502`, `503`, or `504`.
|
|
112
|
+
*/
|
|
113
|
+
shouldRetry?: (context: HmrReadyFetchRetryContext) => boolean | Promise<boolean>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wraps a fetch handler so requests wait for the current HMR generation to be ready.
|
|
118
|
+
*
|
|
119
|
+
* If the wrapped fetch handler returns a retryable response or throws a retryable error, the
|
|
120
|
+
* request is attempted again only when the runner moved to a new generation while the request was
|
|
121
|
+
* in flight.
|
|
122
|
+
*
|
|
123
|
+
* @param runner HMR runner that controls server readiness.
|
|
124
|
+
* @param fetch Fetch handler to call once the runner is ready.
|
|
125
|
+
* @param options Retry behavior for responses and thrown errors.
|
|
126
|
+
* @returns A fetch handler that waits for HMR readiness before forwarding requests.
|
|
127
|
+
*/
|
|
128
|
+
export function createHmrReadyFetch(
|
|
129
|
+
runner: NodeHmrRunner,
|
|
130
|
+
fetch: (request: Request) => Response | Promise<Response>,
|
|
131
|
+
options: HmrReadyFetchOptions = {},
|
|
132
|
+
): (request: Request) => Promise<Response> {
|
|
133
|
+
let shouldRetry = options.shouldRetry ?? shouldRetrySafeUnavailableRequest
|
|
134
|
+
|
|
135
|
+
return async (request) => {
|
|
136
|
+
while (true) {
|
|
137
|
+
await runner.ready()
|
|
138
|
+
let generation = runner.generation
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
let response = await fetch(request)
|
|
142
|
+
|
|
143
|
+
if (!(await shouldRetry({ generation, request, response }))) {
|
|
144
|
+
return response
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
await runner.ready()
|
|
148
|
+
if (runner.generation !== generation) continue
|
|
149
|
+
return response
|
|
150
|
+
} catch (error) {
|
|
151
|
+
await runner.ready()
|
|
152
|
+
if (
|
|
153
|
+
runner.generation !== generation &&
|
|
154
|
+
(await shouldRetry({ error, generation, request }))
|
|
155
|
+
) {
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Starts a Node.js entry module in a supervised child process and watches its loaded module graph.
|
|
166
|
+
*
|
|
167
|
+
* Accepted module changes are applied in place; unaccepted changes restart the child. The returned
|
|
168
|
+
* handle exposes readiness across both paths and closes the watcher, child process, and browser HMR
|
|
169
|
+
* event server when stopped.
|
|
170
|
+
*
|
|
171
|
+
* @param entry Entry module path, resolved from `options.cwd`.
|
|
172
|
+
* @param options Runner options.
|
|
173
|
+
* @returns A runner handle for the supervised process.
|
|
174
|
+
*/
|
|
175
|
+
export function run(entry: string, options: RunOptions = {}): NodeHmrRunner {
|
|
176
|
+
let supervisor = createHmrSupervisor({
|
|
177
|
+
browserHmrChannel: normalizeBrowserHmrChannelOptions(options.browserHmrChannel),
|
|
178
|
+
cwd: options.cwd ?? process.cwd(),
|
|
179
|
+
entry,
|
|
180
|
+
entryArgs: [...(options.entryArgs ?? [])],
|
|
181
|
+
env: options.env ?? process.env,
|
|
182
|
+
nodeArgs: [...(options.nodeArgs ?? [])],
|
|
183
|
+
registerPath: resolveRegisterPath(),
|
|
184
|
+
watch: options.watch,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
let closed = supervisor.start()
|
|
188
|
+
closed.catch((error: unknown) => {
|
|
189
|
+
console.error(error)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
close() {
|
|
194
|
+
return supervisor.stop()
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
get generation() {
|
|
198
|
+
return supervisor.generation
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
ready() {
|
|
202
|
+
return supervisor.ready()
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function shouldRetrySafeUnavailableRequest({
|
|
208
|
+
request,
|
|
209
|
+
response,
|
|
210
|
+
}: HmrReadyFetchRetryContext): boolean {
|
|
211
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return false
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
response === undefined ||
|
|
215
|
+
response.status === 502 ||
|
|
216
|
+
response.status === 503 ||
|
|
217
|
+
response.status === 504
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeBrowserHmrChannelOptions(
|
|
222
|
+
options: RunOptions['browserHmrChannel'],
|
|
223
|
+
): BrowserHmrChannelOptions | null {
|
|
224
|
+
if (options === false) return null
|
|
225
|
+
if (options === undefined || options === true) return {}
|
|
226
|
+
|
|
227
|
+
if (options.port !== undefined) {
|
|
228
|
+
assertValidPort(options.port)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return options
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function resolveRegisterPath(): string {
|
|
235
|
+
let extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js'
|
|
236
|
+
|
|
237
|
+
return fileURLToPath(new URL(`./register.${extension}`, import.meta.url))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function assertValidPort(port: number): void {
|
|
241
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
242
|
+
throw new TypeError(`Invalid browser HMR channel port: ${port}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { hasNodeHmrParentProcess } from './process-state.ts'
|
|
4
|
+
|
|
5
|
+
export const defaultBrowserHmrPathname = '/hmr'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Event payload sent to browser HMR clients.
|
|
9
|
+
*/
|
|
10
|
+
export interface HmrEventPayload {
|
|
11
|
+
/** Event type string consumed by browser HMR clients. */
|
|
12
|
+
type: string
|
|
13
|
+
[key: string]: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** JavaScript or CSS module update sent to a browser HMR client. */
|
|
17
|
+
export type HmrBrowserUpdate =
|
|
18
|
+
| {
|
|
19
|
+
/** Importing module whose dependency-accept handler accepts this update. */
|
|
20
|
+
acceptedPath?: string
|
|
21
|
+
/** Public URL of the changed JavaScript module. */
|
|
22
|
+
path: string
|
|
23
|
+
/** Identifies a JavaScript module update. */
|
|
24
|
+
type: 'js'
|
|
25
|
+
}
|
|
26
|
+
| {
|
|
27
|
+
/** Public URL of the changed stylesheet. */
|
|
28
|
+
path: string
|
|
29
|
+
/** Identifies a stylesheet update. */
|
|
30
|
+
type: 'css'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Browser HMR event emitted to connected clients.
|
|
35
|
+
*/
|
|
36
|
+
export type BrowserHmrEvent =
|
|
37
|
+
| {
|
|
38
|
+
/** Absolute source file paths that triggered this update. */
|
|
39
|
+
files?: string[]
|
|
40
|
+
/** Update timestamp used to bust module and stylesheet caches. */
|
|
41
|
+
timestamp: number
|
|
42
|
+
/** Browser update event. */
|
|
43
|
+
type: 'update'
|
|
44
|
+
/** JavaScript and CSS updates for the browser to apply. */
|
|
45
|
+
updates: HmrBrowserUpdate[]
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
/** Absolute source file paths that could not be handled in place. */
|
|
49
|
+
files?: string[]
|
|
50
|
+
/** Browser reload event. */
|
|
51
|
+
type: 'reload'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* File watcher event reported to a browser HMR channel.
|
|
56
|
+
*/
|
|
57
|
+
export type BrowserHmrFileEvent = {
|
|
58
|
+
/** Filesystem operation observed by the parent watcher. */
|
|
59
|
+
event: 'add' | 'change' | 'unlink'
|
|
60
|
+
/** Absolute path of the source file that changed. */
|
|
61
|
+
filePath: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Handles file events and returns browser HMR events to emit.
|
|
66
|
+
*
|
|
67
|
+
* @param events File additions, changes, and removals reported together by the parent watcher.
|
|
68
|
+
* @returns Browser events to publish in their returned order.
|
|
69
|
+
*/
|
|
70
|
+
export type BrowserHmrFileEventHandler = (
|
|
71
|
+
events: readonly BrowserHmrFileEvent[],
|
|
72
|
+
) => Promise<readonly BrowserHmrEvent[]>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Watched file delta for a browser HMR channel.
|
|
76
|
+
*/
|
|
77
|
+
export interface BrowserHmrWatchedFileDelta {
|
|
78
|
+
/** Absolute source file paths newly required by this channel. */
|
|
79
|
+
add: readonly string[]
|
|
80
|
+
/** Absolute source file paths no longer required by this channel. */
|
|
81
|
+
remove: readonly string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Child-process bridge between browser asset tooling and the parent `node-hmr` runtime.
|
|
86
|
+
*
|
|
87
|
+
* The channel contributes files to the parent's shared watcher and converts matching file changes
|
|
88
|
+
* into browser update or reload events. Close it when its owning asset server shuts down.
|
|
89
|
+
*/
|
|
90
|
+
export interface BrowserHmrChannel {
|
|
91
|
+
/** Absolute URL of the parent-owned EventSource endpoint for browser HMR clients. */
|
|
92
|
+
readonly url: string
|
|
93
|
+
/** Closes this channel, unregisters its handlers, and removes its files from the parent watcher. */
|
|
94
|
+
close(): void
|
|
95
|
+
/**
|
|
96
|
+
* Registers a handler that converts matching watcher events into events for browser clients.
|
|
97
|
+
*
|
|
98
|
+
* Multiple handlers may be registered; their returned browser events are concatenated. Calling
|
|
99
|
+
* the returned cleanup function stops invoking this handler without closing the channel.
|
|
100
|
+
*
|
|
101
|
+
* @param handler Callback that maps a batch of file changes to browser HMR events.
|
|
102
|
+
* @returns A cleanup function that unregisters only this handler.
|
|
103
|
+
*/
|
|
104
|
+
onFileEvents(handler: BrowserHmrFileEventHandler): () => void
|
|
105
|
+
/**
|
|
106
|
+
* Adds and removes absolute file paths from the parent process's watcher for this channel.
|
|
107
|
+
*
|
|
108
|
+
* Paths remain watched until removed by a later delta or until the channel is closed. Repeated
|
|
109
|
+
* additions and removals are idempotent.
|
|
110
|
+
*
|
|
111
|
+
* @param delta Files to add and remove from the watcher.
|
|
112
|
+
*/
|
|
113
|
+
updateWatchedFiles(delta: BrowserHmrWatchedFileDelta): void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function sendHmrEventPayload(payload: HmrEventPayload): void {
|
|
117
|
+
if (!hasNodeHmrParentProcess()) return
|
|
118
|
+
|
|
119
|
+
process.send?.({
|
|
120
|
+
payload,
|
|
121
|
+
type: 'node-hmr:child:browser-event-emitted',
|
|
122
|
+
})
|
|
123
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import { hasNodeHmrParentProcess } from './process-state.ts'
|
|
4
|
+
|
|
5
|
+
export type ServerHmrEvent =
|
|
6
|
+
| {
|
|
7
|
+
acceptedUrl?: string
|
|
8
|
+
filePath: string
|
|
9
|
+
timestamp: number
|
|
10
|
+
type: 'update'
|
|
11
|
+
url: string
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
filePath?: string
|
|
15
|
+
reason?: string
|
|
16
|
+
timestamp: number
|
|
17
|
+
type: 'restart'
|
|
18
|
+
url?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ServerHmrEventSource {
|
|
22
|
+
subscribe(listener: (event: ServerHmrEvent) => void): () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ServerHmrEventListener = (event: ServerHmrEvent) => void
|
|
26
|
+
|
|
27
|
+
class ServerHmrEvents implements ServerHmrEventSource {
|
|
28
|
+
#listeners = new Set<ServerHmrEventListener>()
|
|
29
|
+
|
|
30
|
+
subscribe(listener: ServerHmrEventListener): () => void {
|
|
31
|
+
this.#listeners.add(listener)
|
|
32
|
+
return () => {
|
|
33
|
+
this.#listeners.delete(listener)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
emit(event: ServerHmrEvent): void {
|
|
38
|
+
for (let listener of this.#listeners) {
|
|
39
|
+
listener(event)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const serverHmrEvents = new ServerHmrEvents()
|
|
45
|
+
|
|
46
|
+
export function emitServerHmrEvent(event: ServerHmrEvent): void {
|
|
47
|
+
serverHmrEvents.emit(event)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function emitServerHmrUpdate(event: Extract<ServerHmrEvent, { type: 'update' }>): void {
|
|
51
|
+
emitServerHmrEvent(event)
|
|
52
|
+
if (!hasNodeHmrParentProcess()) return
|
|
53
|
+
|
|
54
|
+
process.send?.({
|
|
55
|
+
acceptedUrl: event.acceptedUrl,
|
|
56
|
+
filePath: event.filePath,
|
|
57
|
+
timestamp: event.timestamp,
|
|
58
|
+
type: 'node-hmr:child:hot-module-updated',
|
|
59
|
+
url: event.url,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { parseSync, visitorKeys } from 'oxc-parser'
|
|
2
|
+
import type { Node, Program } from 'oxc-parser'
|
|
3
|
+
|
|
4
|
+
export interface NodeHmrAnalysis {
|
|
5
|
+
acceptedDeps: NodeHmrAcceptedDependency[]
|
|
6
|
+
selfAccepting: boolean
|
|
7
|
+
usesImportMetaHot: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ResolvedNodeHmrAnalysis {
|
|
11
|
+
acceptedDeps: string[]
|
|
12
|
+
selfAccepting: boolean
|
|
13
|
+
usesImportMetaHot: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NodeHmrAcceptedDependency {
|
|
17
|
+
end: number
|
|
18
|
+
specifier: string
|
|
19
|
+
start: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const invalidAcceptMessage =
|
|
23
|
+
'import.meta.hot.accept() can only accept a callback, a string literal, or an array of string literals.'
|
|
24
|
+
|
|
25
|
+
export function analyzeNodeHmrSource(importerUrl: string, source: string): NodeHmrAnalysis {
|
|
26
|
+
if (!source.includes('import.meta.hot')) {
|
|
27
|
+
return {
|
|
28
|
+
acceptedDeps: [],
|
|
29
|
+
selfAccepting: false,
|
|
30
|
+
usesImportMetaHot: false,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let acceptedDeps: NodeHmrAcceptedDependency[] = []
|
|
35
|
+
let selfAccepting = false
|
|
36
|
+
let usesImportMetaHot = false
|
|
37
|
+
let parseResult = parseSync('node-hmr-analysis.js', source, {
|
|
38
|
+
lang: 'js',
|
|
39
|
+
sourceType: 'module',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (parseResult.errors.length > 0) {
|
|
43
|
+
return {
|
|
44
|
+
acceptedDeps: [],
|
|
45
|
+
selfAccepting: false,
|
|
46
|
+
usesImportMetaHot: false,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
walkAst(parseResult.program, (node) => {
|
|
51
|
+
if (isImportMetaHotNode(node)) {
|
|
52
|
+
usesImportMetaHot = true
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (node.type !== 'CallExpression') return
|
|
56
|
+
if (!isImportMetaHotAcceptCallee(node.callee)) return
|
|
57
|
+
|
|
58
|
+
let [firstArgument] = node.arguments
|
|
59
|
+
if (firstArgument === undefined || isSelfAcceptArgument(firstArgument)) {
|
|
60
|
+
selfAccepting = true
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let deps = getAcceptedDependencies(firstArgument)
|
|
65
|
+
if (deps === null) {
|
|
66
|
+
throw new TypeError(invalidAcceptMessage)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
acceptedDeps.push(...deps)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
acceptedDeps,
|
|
74
|
+
selfAccepting,
|
|
75
|
+
usesImportMetaHot,
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isImportMetaHotAcceptCallee(node: Node): boolean {
|
|
80
|
+
let callee = unwrapChainExpression(node)
|
|
81
|
+
if (callee.type !== 'MemberExpression') return false
|
|
82
|
+
if (callee.computed || !isIdentifierNode(callee.property, 'accept')) return false
|
|
83
|
+
|
|
84
|
+
return isImportMetaHotNode(callee.object)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isImportMetaHotNode(node: Node): boolean {
|
|
88
|
+
let hot = unwrapChainExpression(node)
|
|
89
|
+
if (hot.type !== 'MemberExpression') return false
|
|
90
|
+
if (hot.computed || !isIdentifierNode(hot.property, 'hot')) return false
|
|
91
|
+
|
|
92
|
+
let meta = unwrapChainExpression(hot.object)
|
|
93
|
+
return (
|
|
94
|
+
meta.type === 'MetaProperty' &&
|
|
95
|
+
isIdentifierNode(meta.meta, 'import') &&
|
|
96
|
+
isIdentifierNode(meta.property, 'meta')
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unwrapChainExpression(node: Node): Node {
|
|
101
|
+
return node.type === 'ChainExpression' ? node.expression : node
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isIdentifierNode(node: Node, name: string): boolean {
|
|
105
|
+
return node.type === 'Identifier' && node.name === name
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isSelfAcceptArgument(node: Node): boolean {
|
|
109
|
+
return (
|
|
110
|
+
node.type === 'FunctionExpression' ||
|
|
111
|
+
node.type === 'ArrowFunctionExpression' ||
|
|
112
|
+
isIdentifierNode(node, 'undefined')
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getAcceptedDependencies(node: Node): NodeHmrAcceptedDependency[] | null {
|
|
117
|
+
if (isStringLiteralNode(node)) {
|
|
118
|
+
return [toAcceptedDependency(node)]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (node.type !== 'ArrayExpression') return null
|
|
122
|
+
|
|
123
|
+
let deps: NodeHmrAcceptedDependency[] = []
|
|
124
|
+
for (let element of node.elements) {
|
|
125
|
+
if (!isStringLiteralNode(element)) return null
|
|
126
|
+
deps.push(toAcceptedDependency(element))
|
|
127
|
+
}
|
|
128
|
+
return deps
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toAcceptedDependency(node: Node & { end: number; start: number; value: string }) {
|
|
132
|
+
return {
|
|
133
|
+
end: node.end - 1,
|
|
134
|
+
specifier: node.value,
|
|
135
|
+
start: node.start + 1,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function walkAst(node: Program | Node, visit: (node: Program | Node) => void): void {
|
|
140
|
+
visit(node)
|
|
141
|
+
|
|
142
|
+
let keys = visitorKeys[node.type]
|
|
143
|
+
if (!keys) return
|
|
144
|
+
|
|
145
|
+
let walkableNode = node as unknown as Record<string, unknown>
|
|
146
|
+
for (let key of keys) {
|
|
147
|
+
let value = walkableNode[key]
|
|
148
|
+
if (Array.isArray(value)) {
|
|
149
|
+
for (let child of value) {
|
|
150
|
+
if (isAstNode(child)) {
|
|
151
|
+
walkAst(child, visit)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isAstNode(value)) {
|
|
158
|
+
walkAst(value, visit)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isAstNode(value: unknown): value is Node {
|
|
164
|
+
return typeof value === 'object' && value !== null && 'type' in value
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isStringLiteralNode(node: Node | null | undefined): node is Node & {
|
|
168
|
+
end: number
|
|
169
|
+
start: number
|
|
170
|
+
value: string
|
|
171
|
+
} {
|
|
172
|
+
return (
|
|
173
|
+
node?.type === 'Literal' &&
|
|
174
|
+
typeof node.end === 'number' &&
|
|
175
|
+
typeof node.start === 'number' &&
|
|
176
|
+
typeof node.value === 'string'
|
|
177
|
+
)
|
|
178
|
+
}
|