@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shopify Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,307 @@
1
- # Placeholder Package
1
+ # node-hmr
2
2
 
3
- This package is a placeholder published at 0.0.0 to reserve the package name and configure CI publish permissions.
3
+ Run Node.js applications with Hot Module Reloading.
4
+
5
+ ## Features
6
+
7
+ - **HMR Runtime**: Provides an `import.meta.hot` API for modules that can handle hot updates
8
+ - **Module Hook Friendly**: Use Node's module customization hooks API to automatically insert `import.meta.hot` usage
9
+ - **Restart Fallback**: Restarts the child Node process when updates aren't accepted
10
+ - **Fetch Proxy Support**: Wrap fetch handlers so requests are delayed/retried during server updates/restarts
11
+ - **Browser HMR Integration**: Optionally hosts browser HMR coordination that survives child restarts
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ npm i remix
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ Create a development script that starts your app server with HMR enabled, along with any additional Node args, such as the `--import` flag to provide [Node module customization hooks](https://nodejs.org/api/module.html#customization-hooks) for [JSX syntax support](https://github.com/remix-run/remix/tree/main/packages/node-tsx) and [Remix component HMR](https://github.com/remix-run/remix/tree/main/packages/ui-hmr):
22
+
23
+ ```ts
24
+ // hmr.ts
25
+ import { run } from 'remix/node-hmr'
26
+
27
+ run('./server.ts', {
28
+ nodeArgs: ['--import', 'remix/node-tsx', '--import', 'remix/ui-hmr/node'],
29
+ watch: {
30
+ ignore: ['**/node_modules/**'],
31
+ },
32
+ })
33
+ ```
34
+
35
+ Then run the script with Node:
36
+
37
+ ```json
38
+ {
39
+ "scripts": {
40
+ "hmr": "NODE_ENV=development node hmr.ts"
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Fetch Proxy Support
46
+
47
+ During development, server updates can briefly leave your app unable to handle requests. In a server-only context, requests may be rejected while the child server is restarting. In a browser context, the browser may refresh or revalidate at the same time as a server restart, which can result in failed requests or a broken page.
48
+
49
+ A stable proxy server can avoid this by continuing to listen on the public port while `node-hmr` updates the child server behind it. `createHmrReadyFetch()` works with any fetch handler, so you can compose it with `createFetchProxy()` from [`remix/fetch-proxy`](https://github.com/remix-run/remix/tree/main/packages/fetch-proxy) to forward requests to the child server while delaying or retrying requests during updates.
50
+
51
+ ```ts
52
+ // hmr.ts
53
+ import * as http from 'node:http'
54
+
55
+ import { createFetchProxy } from 'remix/fetch-proxy'
56
+ import { run, createHmrReadyFetch } from 'remix/node-hmr'
57
+ import { createRequestListener } from 'remix/node-fetch-server'
58
+
59
+ const hmrProxyPort = 44100
60
+ const appPort = 44101
61
+
62
+ const hmrRunner = run('./server.ts', {
63
+ env: {
64
+ ...process.env,
65
+ PORT: String(appPort),
66
+ },
67
+ nodeArgs: ['--import', 'remix/node-tsx'],
68
+ })
69
+
70
+ const proxyFetch = createFetchProxy(`http://127.0.0.1:${appPort}`, {
71
+ xForwardedHeaders: true,
72
+ })
73
+
74
+ const server = http.createServer(createRequestListener(createHmrReadyFetch(hmrRunner, proxyFetch)))
75
+
76
+ server.listen(hmrProxyPort)
77
+ ```
78
+
79
+ By default, `createHmrReadyFetch()` retries `GET` and `HEAD` requests when the wrapped fetch handler throws or returns a `502`, `503`, or `504` response, but only if the server updated or restarted while the request was in flight. You can customize this policy with `shouldRetry`:
80
+
81
+ ```ts
82
+ let fetchWhenReady = createHmrReadyFetch(hmrRunner, proxyFetch, {
83
+ shouldRetry({ request, response }) {
84
+ if (request.method !== 'GET' && request.method !== 'HEAD') return false
85
+
86
+ return response === undefined || [502, 503, 504].includes(response.status)
87
+ },
88
+ })
89
+ ```
90
+
91
+ ## Browser HMR Integration
92
+
93
+ `node-hmr` can coordinate browser-facing HMR alongside server HMR. The parent process hosts the browser event stream, tracks files reported by asset servers in the child process, sends matching file events back to the child runtime, and emits the resulting browser updates to connected clients.
94
+
95
+ This is co-ordinated through the use of a browser HMR channel which can be created within the app server when running in `node-hmr` via the `remix/node-hmr/runtime` import:
96
+
97
+ ```ts
98
+ import { createBrowserHmrChannel } from 'remix/node-hmr/runtime'
99
+
100
+ let browserHmrChannel = await createBrowserHmrChannel()
101
+ ```
102
+
103
+ The `remix/node-hmr/runtime` API is only available inside a child process supervised by `node-hmr`. Importing it outside `node-hmr` throws. Supervised child processes automatically receive the `REMIX_NODE_HMR` environment variable which you can check before dynamically importing the runtime API:
104
+
105
+ ```ts
106
+ if (process.env.REMIX_NODE_HMR) {
107
+ let { createBrowserHmrChannel } = await import('remix/node-hmr/runtime')
108
+ let browserHmrChannel = await createBrowserHmrChannel()
109
+ }
110
+ ```
111
+
112
+ A browser HMR channel is scoped to the current child process. It gives browser HMR tooling an EventSource URL, a way to report the files it wants watched, and a way to respond to file changes with browser HMR events.
113
+
114
+ Browser asset servers can use this API to co-ordinate browser HMR with the server, for example, [`remix/assets`](https://github.com/remix-run/remix/tree/main/packages/assets) via its `hmr` option to `createAssetServer`:
115
+
116
+ ```ts
117
+ import { createAssetServer } from 'remix/assets'
118
+
119
+ let isDevelopment = process.env.NODE_ENV === 'development'
120
+
121
+ let assetServer = createAssetServer({
122
+ basePath: '/assets',
123
+ fileMap: { '/app/*path': 'app/*path' },
124
+ allowFiles: ['app/routes.ts', 'app/**/public/**'],
125
+ denyFiles: ['app/**/*.test.*'],
126
+ hmr:
127
+ isDevelopment && process.env.REMIX_NODE_HMR
128
+ ? async () => (await import('remix/node-hmr/runtime')).createBrowserHmrChannel()
129
+ : undefined,
130
+ watch: isDevelopment,
131
+ })
132
+ ```
133
+
134
+ When `node-hmr` hot updates or restarts server code in a way that should refresh server-rendered UI, it sends a `server:update` event to connected clients.
135
+
136
+ Call `emitServerReady()` when your app server is ready to receive requests. This lets the parent process delay browser `server:update` events until a restarted app server has finished listening:
137
+
138
+ ```ts
139
+ server.listen(port, () => {
140
+ if (process.env.REMIX_NODE_HMR) {
141
+ import('remix/node-hmr/runtime').then((nodeHmr) => nodeHmr.emitServerReady())
142
+ }
143
+ })
144
+ ```
145
+
146
+ ## File Watching
147
+
148
+ The file system is watched automatically so server source changes can hot update or restart the child process.
149
+
150
+ You can optionally provide an array of glob patterns to the `watch.ignore` option.
151
+
152
+ ```ts
153
+ import { run } from 'remix/node-hmr'
154
+
155
+ run('./server.ts', {
156
+ nodeArgs: ['--import', 'remix/node-tsx', '--import', 'remix/ui-hmr/node'],
157
+ watch: {
158
+ ignore: ['**/node_modules/**'],
159
+ },
160
+ })
161
+ ```
162
+
163
+ You can also configure polling behavior. Polling defaults to `true` on Windows and `false` elsewhere:
164
+
165
+ ```ts
166
+ import { run } from 'remix/node-hmr'
167
+
168
+ run('./server.ts', {
169
+ nodeArgs: ['--import', 'remix/node-tsx', '--import', 'remix/ui-hmr/node'],
170
+ watch: {
171
+ poll: true,
172
+ pollInterval: 100,
173
+ },
174
+ })
175
+ ```
176
+
177
+ ## `import.meta.hot`
178
+
179
+ The `import.meta.hot` API provided by `node-hmr` is a small runtime contract for modules that can handle updates without restarting the process. It is primarily intended for transforms like [remix/ui-hmr](https://github.com/remix-run/remix/tree/main/packages/ui-hmr), but it can also be used directly.
180
+
181
+ To type `import.meta.hot`, add the HMR types to your TypeScript config:
182
+
183
+ ```json
184
+ {
185
+ "compilerOptions": {
186
+ "types": ["remix/node-hmr/types"]
187
+ }
188
+ }
189
+ ```
190
+
191
+ HMR accept calls are statically analyzed. Write them directly as `import.meta.hot.accept(...)`. Dependency accepts must use string literals or arrays of string literals; do not alias `import.meta.hot` or pass dynamically constructed dependency lists.
192
+
193
+ ```ts
194
+ if (import.meta.hot) {
195
+ import.meta.hot.accept()
196
+ }
197
+ ```
198
+
199
+ For consistency with browser HMR environments, `node-hmr` also implements `import.meta.hot.on(...)`, but no events are fired in server modules.
200
+
201
+ ### Accepting updates
202
+
203
+ Calling `accept()` makes the current module an HMR boundary. When the module changes, `node-hmr` evaluates the updated module and calls your callback with its exports.
204
+
205
+ ```ts
206
+ export let value = 1
207
+
208
+ if (import.meta.hot) {
209
+ import.meta.hot.accept((module) => {
210
+ if (typeof module.value !== 'number') {
211
+ import.meta.hot?.invalidate('Updated module no longer exports value')
212
+ return
213
+ }
214
+
215
+ value = module.value
216
+ })
217
+ }
218
+ ```
219
+
220
+ You can also accept updates from direct dependencies.
221
+
222
+ ```ts
223
+ import { value } from './value.ts'
224
+
225
+ let currentValue = value
226
+
227
+ export function readValue() {
228
+ return currentValue
229
+ }
230
+
231
+ if (import.meta.hot) {
232
+ import.meta.hot.accept('./value.ts', (module) => {
233
+ if (typeof module.value !== 'number') {
234
+ import.meta.hot?.invalidate('Updated dependency no longer exports value')
235
+ return
236
+ }
237
+
238
+ currentValue = module.value
239
+ })
240
+ }
241
+ ```
242
+
243
+ Multiple dependencies can be accepted at once. The callback receives an array where only the changed dependency is defined.
244
+
245
+ ```ts
246
+ if (import.meta.hot) {
247
+ import.meta.hot.accept(['./one.ts', './two.ts'], ([oneModule, twoModule]) => {
248
+ // oneModule is defined when ./one.ts changed.
249
+ // twoModule is defined when ./two.ts changed.
250
+ })
251
+ }
252
+ ```
253
+
254
+ ### Cleaning up
255
+
256
+ Register cleanup that should run before the module is replaced or disposed.
257
+
258
+ ```ts
259
+ let interval = setInterval(refreshCache, 30_000)
260
+
261
+ if (import.meta.hot) {
262
+ import.meta.hot.dispose(() => {
263
+ clearInterval(interval)
264
+ })
265
+ }
266
+ ```
267
+
268
+ The `data` object is preserved across updates for the same module. Use it for small pieces of state.
269
+
270
+ ```ts
271
+ let count = Number(import.meta.hot?.data.count ?? 0)
272
+
273
+ export function increment() {
274
+ count++
275
+ }
276
+
277
+ if (import.meta.hot) {
278
+ import.meta.hot.dispose((data) => {
279
+ data.count = count
280
+ })
281
+ }
282
+ ```
283
+
284
+ ### Invalidating updates
285
+
286
+ Call `invalidate()` inside an accept callback when the update cannot be applied safely. `node-hmr` falls back to a process restart.
287
+
288
+ ```ts
289
+ if (import.meta.hot) {
290
+ import.meta.hot.accept((module) => {
291
+ if (typeof module.value !== 'number') {
292
+ import.meta.hot?.invalidate('Updated module no longer exports value')
293
+ return
294
+ }
295
+ })
296
+ }
297
+ ```
298
+
299
+ ## Related Packages
300
+
301
+ - [`assets`](https://github.com/remix-run/remix/tree/main/packages/assets) - Consumes browser HMR channels for coordinating server and browser HMR updates
302
+ - [`fetch-proxy`](https://github.com/remix-run/remix/tree/main/packages/fetch-proxy) - Creates fetch handlers for forwarding requests to another server
303
+ - [`ui-hmr`](https://github.com/remix-run/remix/tree/main/packages/ui-hmr) - Provides code transforms and runtime for HMR for Remix UI components
304
+
305
+ ## License
306
+
307
+ See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Options for running a Node.js entry module with HMR supervision.
3
+ */
4
+ export interface RunOptions {
5
+ /**
6
+ * Configures the parent-owned EventSource server used to coordinate browser HMR, or disables it
7
+ * with `false`. Enabled with default options when omitted or set to `true`.
8
+ */
9
+ browserHmrChannel?: boolean | BrowserHmrChannelOptions;
10
+ /** Working directory used to resolve the entry path and relative watch options. (`process.cwd()`) */
11
+ cwd?: string;
12
+ /** Arguments passed to the entry module after the entry path. */
13
+ entryArgs?: readonly string[];
14
+ /** Complete environment for the child process. (`process.env`) */
15
+ env?: NodeJS.ProcessEnv;
16
+ /** Node.js arguments passed before the entry path. */
17
+ nodeArgs?: readonly string[];
18
+ /** File watching options for the supervised process. */
19
+ watch?: NodeHmrWatchOptions;
20
+ }
21
+ /**
22
+ * Browser HMR event stream options hosted by the parent process.
23
+ */
24
+ export interface BrowserHmrChannelOptions {
25
+ /** Hostname for the browser HMR event server. (`'127.0.0.1'`) */
26
+ host?: string;
27
+ /** Port for the browser HMR event server. Uses an available ephemeral port when omitted. */
28
+ port?: number;
29
+ /** URL pathname for the browser HMR event stream. (`'/hmr'`) */
30
+ pathname?: string;
31
+ }
32
+ /**
33
+ * File watching options for a Node HMR runner.
34
+ */
35
+ export interface NodeHmrWatchOptions {
36
+ /**
37
+ * Ignore matching glob patterns or file paths. Relative values are resolved
38
+ * from the runner's `cwd`.
39
+ */
40
+ ignore?: readonly string[];
41
+ /**
42
+ * Use polling instead of native filesystem events. Defaults to `true` on
43
+ * Windows and `false` elsewhere.
44
+ */
45
+ poll?: boolean;
46
+ /**
47
+ * Polling interval in milliseconds when `poll` is enabled. Defaults to `100`.
48
+ */
49
+ pollInterval?: number;
50
+ }
51
+ /**
52
+ * Handle returned by {@link run} for controlling the supervised process.
53
+ */
54
+ export interface NodeHmrRunner {
55
+ /**
56
+ * Stops the runner and waits for the child process to exit.
57
+ *
58
+ * @returns A promise that resolves once the runner has stopped.
59
+ */
60
+ close(): Promise<void>;
61
+ /**
62
+ * Current server generation, incremented after every accepted hot update or process restart.
63
+ */
64
+ readonly generation: number;
65
+ /**
66
+ * Waits until the latest update or restart has settled and the current child process is ready.
67
+ *
68
+ * If the app uses `emitServerReady()`, restart readiness also waits for that signal.
69
+ *
70
+ * @returns A promise that resolves when the latest requested generation is ready.
71
+ */
72
+ ready(): Promise<void>;
73
+ }
74
+ type HmrReadyFetchRetryContext = {
75
+ /** Child process lifecycle generation that handled the fetch attempt. */
76
+ generation: number;
77
+ /** Request passed to the wrapped fetch handler. */
78
+ request: Request;
79
+ } & ({
80
+ /** Error thrown by the wrapped fetch handler. */
81
+ error: unknown;
82
+ /** Response is absent when the wrapped fetch handler throws. */
83
+ response?: never;
84
+ } | {
85
+ /** Error is absent when the wrapped fetch handler returns a response. */
86
+ error?: never;
87
+ /** Response returned by the wrapped fetch handler. */
88
+ response: Response;
89
+ });
90
+ /**
91
+ * Options for {@link createHmrReadyFetch}.
92
+ */
93
+ export interface HmrReadyFetchOptions {
94
+ /**
95
+ * Determines whether a response or thrown error should be retried if the
96
+ * runner moves to a new generation while the request is in flight. Defaults
97
+ * to retrying `GET` and `HEAD` requests when the fetch throws or returns
98
+ * `502`, `503`, or `504`.
99
+ */
100
+ shouldRetry?: (context: HmrReadyFetchRetryContext) => boolean | Promise<boolean>;
101
+ }
102
+ /**
103
+ * Wraps a fetch handler so requests wait for the current HMR generation to be ready.
104
+ *
105
+ * If the wrapped fetch handler returns a retryable response or throws a retryable error, the
106
+ * request is attempted again only when the runner moved to a new generation while the request was
107
+ * in flight.
108
+ *
109
+ * @param runner HMR runner that controls server readiness.
110
+ * @param fetch Fetch handler to call once the runner is ready.
111
+ * @param options Retry behavior for responses and thrown errors.
112
+ * @returns A fetch handler that waits for HMR readiness before forwarding requests.
113
+ */
114
+ export declare function createHmrReadyFetch(runner: NodeHmrRunner, fetch: (request: Request) => Response | Promise<Response>, options?: HmrReadyFetchOptions): (request: Request) => Promise<Response>;
115
+ /**
116
+ * Starts a Node.js entry module in a supervised child process and watches its loaded module graph.
117
+ *
118
+ * Accepted module changes are applied in place; unaccepted changes restart the child. The returned
119
+ * handle exposes readiness across both paths and closes the watcher, child process, and browser HMR
120
+ * event server when stopped.
121
+ *
122
+ * @param entry Entry module path, resolved from `options.cwd`.
123
+ * @param options Runner options.
124
+ * @returns A runner handle for the supervised process.
125
+ */
126
+ export declare function run(entry: string, options?: RunOptions): NodeHmrRunner;
127
+ export {};
128
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB;;;OAGG;IACH,iBAAiB,CAAC,EAAE,OAAO,GAAG,wBAAwB,CAAA;IACtD,qGAAqG;IACrG,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,iEAAiE;IACjE,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAA;IACvB,sDAAsD;IACtD,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC5B,wDAAwD;IACxD,KAAK,CAAC,EAAE,mBAAmB,CAAA;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,iEAAiE;IACjE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,4FAA4F;IAC5F,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC1B;;;OAGG;IACH,IAAI,CAAC,EAAE,OAAO,CAAA;IACd;;OAEG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B;;;;OAIG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;IACtB;;OAEG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B;;;;;;OAMG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED,KAAK,yBAAyB,GAAG;IAC/B,yEAAyE;IACzE,UAAU,EAAE,MAAM,CAAA;IAClB,mDAAmD;IACnD,OAAO,EAAE,OAAO,CAAA;CACjB,GAAG,CACA;IACE,iDAAiD;IACjD,KAAK,EAAE,OAAO,CAAA;IACd,gEAAgE;IAChE,QAAQ,CAAC,EAAE,KAAK,CAAA;CACjB,GACD;IACE,yEAAyE;IACzE,KAAK,CAAC,EAAE,KAAK,CAAA;IACb,sDAAsD;IACtD,QAAQ,EAAE,QAAQ,CAAA;CACnB,CACJ,CAAA;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB;IACnC;;;;;OAKG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,yBAAyB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;CACjF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,aAAa,EACrB,KAAK,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,EACzD,OAAO,GAAE,oBAAyB,GACjC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA8BzC;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,UAAe,GAAG,aAAa,CA8B1E"}
package/dist/index.js ADDED
@@ -0,0 +1,107 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import process from 'node:process';
3
+ import { createHmrSupervisor } from './lib/runner.js';
4
+ /**
5
+ * Wraps a fetch handler so requests wait for the current HMR generation to be ready.
6
+ *
7
+ * If the wrapped fetch handler returns a retryable response or throws a retryable error, the
8
+ * request is attempted again only when the runner moved to a new generation while the request was
9
+ * in flight.
10
+ *
11
+ * @param runner HMR runner that controls server readiness.
12
+ * @param fetch Fetch handler to call once the runner is ready.
13
+ * @param options Retry behavior for responses and thrown errors.
14
+ * @returns A fetch handler that waits for HMR readiness before forwarding requests.
15
+ */
16
+ export function createHmrReadyFetch(runner, fetch, options = {}) {
17
+ let shouldRetry = options.shouldRetry ?? shouldRetrySafeUnavailableRequest;
18
+ return async (request) => {
19
+ while (true) {
20
+ await runner.ready();
21
+ let generation = runner.generation;
22
+ try {
23
+ let response = await fetch(request);
24
+ if (!(await shouldRetry({ generation, request, response }))) {
25
+ return response;
26
+ }
27
+ await runner.ready();
28
+ if (runner.generation !== generation)
29
+ continue;
30
+ return response;
31
+ }
32
+ catch (error) {
33
+ await runner.ready();
34
+ if (runner.generation !== generation &&
35
+ (await shouldRetry({ error, generation, request }))) {
36
+ continue;
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+ };
42
+ }
43
+ /**
44
+ * Starts a Node.js entry module in a supervised child process and watches its loaded module graph.
45
+ *
46
+ * Accepted module changes are applied in place; unaccepted changes restart the child. The returned
47
+ * handle exposes readiness across both paths and closes the watcher, child process, and browser HMR
48
+ * event server when stopped.
49
+ *
50
+ * @param entry Entry module path, resolved from `options.cwd`.
51
+ * @param options Runner options.
52
+ * @returns A runner handle for the supervised process.
53
+ */
54
+ export function run(entry, options = {}) {
55
+ let supervisor = createHmrSupervisor({
56
+ browserHmrChannel: normalizeBrowserHmrChannelOptions(options.browserHmrChannel),
57
+ cwd: options.cwd ?? process.cwd(),
58
+ entry,
59
+ entryArgs: [...(options.entryArgs ?? [])],
60
+ env: options.env ?? process.env,
61
+ nodeArgs: [...(options.nodeArgs ?? [])],
62
+ registerPath: resolveRegisterPath(),
63
+ watch: options.watch,
64
+ });
65
+ let closed = supervisor.start();
66
+ closed.catch((error) => {
67
+ console.error(error);
68
+ });
69
+ return {
70
+ close() {
71
+ return supervisor.stop();
72
+ },
73
+ get generation() {
74
+ return supervisor.generation;
75
+ },
76
+ ready() {
77
+ return supervisor.ready();
78
+ },
79
+ };
80
+ }
81
+ function shouldRetrySafeUnavailableRequest({ request, response, }) {
82
+ if (request.method !== 'GET' && request.method !== 'HEAD')
83
+ return false;
84
+ return (response === undefined ||
85
+ response.status === 502 ||
86
+ response.status === 503 ||
87
+ response.status === 504);
88
+ }
89
+ function normalizeBrowserHmrChannelOptions(options) {
90
+ if (options === false)
91
+ return null;
92
+ if (options === undefined || options === true)
93
+ return {};
94
+ if (options.port !== undefined) {
95
+ assertValidPort(options.port);
96
+ }
97
+ return options;
98
+ }
99
+ function resolveRegisterPath() {
100
+ let extension = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
101
+ return fileURLToPath(new URL(`./register.${extension}`, import.meta.url));
102
+ }
103
+ function assertValidPort(port) {
104
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
105
+ throw new TypeError(`Invalid browser HMR channel port: ${port}`);
106
+ }
107
+ }
@@ -0,0 +1,99 @@
1
+ export declare const defaultBrowserHmrPathname = "/hmr";
2
+ /**
3
+ * Event payload sent to browser HMR clients.
4
+ */
5
+ export interface HmrEventPayload {
6
+ /** Event type string consumed by browser HMR clients. */
7
+ type: string;
8
+ [key: string]: unknown;
9
+ }
10
+ /** JavaScript or CSS module update sent to a browser HMR client. */
11
+ export type HmrBrowserUpdate = {
12
+ /** Importing module whose dependency-accept handler accepts this update. */
13
+ acceptedPath?: string;
14
+ /** Public URL of the changed JavaScript module. */
15
+ path: string;
16
+ /** Identifies a JavaScript module update. */
17
+ type: 'js';
18
+ } | {
19
+ /** Public URL of the changed stylesheet. */
20
+ path: string;
21
+ /** Identifies a stylesheet update. */
22
+ type: 'css';
23
+ };
24
+ /**
25
+ * Browser HMR event emitted to connected clients.
26
+ */
27
+ export type BrowserHmrEvent = {
28
+ /** Absolute source file paths that triggered this update. */
29
+ files?: string[];
30
+ /** Update timestamp used to bust module and stylesheet caches. */
31
+ timestamp: number;
32
+ /** Browser update event. */
33
+ type: 'update';
34
+ /** JavaScript and CSS updates for the browser to apply. */
35
+ updates: HmrBrowserUpdate[];
36
+ } | {
37
+ /** Absolute source file paths that could not be handled in place. */
38
+ files?: string[];
39
+ /** Browser reload event. */
40
+ type: 'reload';
41
+ };
42
+ /**
43
+ * File watcher event reported to a browser HMR channel.
44
+ */
45
+ export type BrowserHmrFileEvent = {
46
+ /** Filesystem operation observed by the parent watcher. */
47
+ event: 'add' | 'change' | 'unlink';
48
+ /** Absolute path of the source file that changed. */
49
+ filePath: string;
50
+ };
51
+ /**
52
+ * Handles file events and returns browser HMR events to emit.
53
+ *
54
+ * @param events File additions, changes, and removals reported together by the parent watcher.
55
+ * @returns Browser events to publish in their returned order.
56
+ */
57
+ export type BrowserHmrFileEventHandler = (events: readonly BrowserHmrFileEvent[]) => Promise<readonly BrowserHmrEvent[]>;
58
+ /**
59
+ * Watched file delta for a browser HMR channel.
60
+ */
61
+ export interface BrowserHmrWatchedFileDelta {
62
+ /** Absolute source file paths newly required by this channel. */
63
+ add: readonly string[];
64
+ /** Absolute source file paths no longer required by this channel. */
65
+ remove: readonly string[];
66
+ }
67
+ /**
68
+ * Child-process bridge between browser asset tooling and the parent `node-hmr` runtime.
69
+ *
70
+ * The channel contributes files to the parent's shared watcher and converts matching file changes
71
+ * into browser update or reload events. Close it when its owning asset server shuts down.
72
+ */
73
+ export interface BrowserHmrChannel {
74
+ /** Absolute URL of the parent-owned EventSource endpoint for browser HMR clients. */
75
+ readonly url: string;
76
+ /** Closes this channel, unregisters its handlers, and removes its files from the parent watcher. */
77
+ close(): void;
78
+ /**
79
+ * Registers a handler that converts matching watcher events into events for browser clients.
80
+ *
81
+ * Multiple handlers may be registered; their returned browser events are concatenated. Calling
82
+ * the returned cleanup function stops invoking this handler without closing the channel.
83
+ *
84
+ * @param handler Callback that maps a batch of file changes to browser HMR events.
85
+ * @returns A cleanup function that unregisters only this handler.
86
+ */
87
+ onFileEvents(handler: BrowserHmrFileEventHandler): () => void;
88
+ /**
89
+ * Adds and removes absolute file paths from the parent process's watcher for this channel.
90
+ *
91
+ * Paths remain watched until removed by a later delta or until the channel is closed. Repeated
92
+ * additions and removals are idempotent.
93
+ *
94
+ * @param delta Files to add and remove from the watcher.
95
+ */
96
+ updateWatchedFiles(delta: BrowserHmrWatchedFileDelta): void;
97
+ }
98
+ export declare function sendHmrEventPayload(payload: HmrEventPayload): void;
99
+ //# sourceMappingURL=browser-events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser-events.d.ts","sourceRoot":"","sources":["../../src/lib/browser-events.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,yBAAyB,SAAS,CAAA;AAE/C;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAA;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,oEAAoE;AACpE,MAAM,MAAM,gBAAgB,GACxB;IACE,4EAA4E;IAC5E,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,mDAAmD;IACnD,IAAI,EAAE,MAAM,CAAA;IACZ,6CAA6C;IAC7C,IAAI,EAAE,IAAI,CAAA;CACX,GACD;IACE,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAA;IACZ,sCAAsC;IACtC,IAAI,EAAE,KAAK,CAAA;CACZ,CAAA;AAEL;;GAEG;AACH,MAAM,MAAM,eAAe,GACvB;IACE,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAA;IACjB,4BAA4B;IAC5B,IAAI,EAAE,QAAQ,CAAA;IACd,2DAA2D;IAC3D,OAAO,EAAE,gBAAgB,EAAE,CAAA;CAC5B,GACD;IACE,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;IAChB,4BAA4B;IAC5B,IAAI,EAAE,QAAQ,CAAA;CACf,CAAA;AAEL;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG;IAChC,2DAA2D;IAC3D,KAAK,EAAE,KAAK,GAAG,QAAQ,GAAG,QAAQ,CAAA;IAClC,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAA;CACjB,CAAA;AAED;;;;;GAKG;AACH,MAAM,MAAM,0BAA0B,GAAG,CACvC,MAAM,EAAE,SAAS,mBAAmB,EAAE,KACnC,OAAO,CAAC,SAAS,eAAe,EAAE,CAAC,CAAA;AAExC;;GAEG;AACH,MAAM,WAAW,0BAA0B;IACzC,iEAAiE;IACjE,GAAG,EAAE,SAAS,MAAM,EAAE,CAAA;IACtB,qEAAqE;IACrE,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1B;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAiB;IAChC,qFAAqF;IACrF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oGAAoG;IACpG,KAAK,IAAI,IAAI,CAAA;IACb;;;;;;;;OAQG;IACH,YAAY,CAAC,OAAO,EAAE,0BAA0B,GAAG,MAAM,IAAI,CAAA;IAC7D;;;;;;;OAOG;IACH,kBAAkB,CAAC,KAAK,EAAE,0BAA0B,GAAG,IAAI,CAAA;CAC5D;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,GAAG,IAAI,CAOlE"}
@@ -0,0 +1,11 @@
1
+ import process from 'node:process';
2
+ import { hasNodeHmrParentProcess } from './process-state.js';
3
+ export const defaultBrowserHmrPathname = '/hmr';
4
+ export function sendHmrEventPayload(payload) {
5
+ if (!hasNodeHmrParentProcess())
6
+ return;
7
+ process.send?.({
8
+ payload,
9
+ type: 'node-hmr:child:browser-event-emitted',
10
+ });
11
+ }