bippy 0.5.42 → 0.6.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 (50) hide show
  1. package/dist/core.cjs +1 -1
  2. package/dist/core.d.cts +28 -313
  3. package/dist/core.d.ts +28 -313
  4. package/dist/core.js +1 -1
  5. package/dist/core2.d.cts +3 -2
  6. package/dist/core2.d.ts +3 -2
  7. package/dist/get-source.cjs +19 -0
  8. package/dist/get-source.js +19 -0
  9. package/dist/index.cjs +1 -1
  10. package/dist/index.d.cts +3 -2
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.iife.js +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/install-hook-only.cjs +1 -1
  15. package/dist/install-hook-only.iife.js +1 -1
  16. package/dist/install-hook-only.js +1 -1
  17. package/dist/rdt-hook.cjs +1 -1
  18. package/dist/rdt-hook.js +1 -1
  19. package/dist/react-refresh.cjs +9 -0
  20. package/dist/react-refresh.d.cts +66 -0
  21. package/dist/react-refresh.d.ts +66 -0
  22. package/dist/react-refresh.js +9 -0
  23. package/dist/source.cjs +4 -19
  24. package/dist/source.d.cts +35 -27
  25. package/dist/source.d.ts +35 -27
  26. package/dist/source.js +4 -19
  27. package/dist/unsubscribe.d.cts +298 -0
  28. package/dist/unsubscribe.d.ts +298 -0
  29. package/package.json +14 -3
  30. package/src/core.ts +312 -334
  31. package/src/rdt-hook.ts +57 -16
  32. package/src/react-refresh/constants.ts +9 -0
  33. package/src/react-refresh/detect-hmr-transport.ts +33 -0
  34. package/src/react-refresh/index.ts +173 -0
  35. package/src/react-refresh/metro-hmr-transport.ts +188 -0
  36. package/src/react-refresh/next-webpack-hmr-transport.ts +72 -0
  37. package/src/react-refresh/normalize-hmr-file-path.ts +24 -0
  38. package/src/react-refresh/types.ts +7 -0
  39. package/src/react-refresh/vite-hmr-transport.ts +116 -0
  40. package/src/source/constants.ts +11 -0
  41. package/src/source/get-display-name-from-source.ts +3 -3
  42. package/src/source/get-source.ts +53 -9
  43. package/src/source/index.ts +17 -8
  44. package/src/source/inspect-hooks.ts +7 -4
  45. package/src/source/owner-stack.ts +193 -29
  46. package/src/source/parse-debug-stack.ts +133 -0
  47. package/src/source/parse-stack.ts +6 -105
  48. package/src/source/symbolication.ts +48 -13
  49. package/src/types.ts +20 -7
  50. package/src/unsubscribe.ts +17 -0
package/src/rdt-hook.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // make sure you import this file first before anything else (particularly React)
5
5
 
6
6
  import type { ReactDevToolsGlobalHook, ReactRenderer } from "./types.js";
7
+ import { toUnsubscribe, type Unsubscribe } from "./unsubscribe.js";
7
8
 
8
9
  export const version = process.env.VERSION;
9
10
  export const BIPPY_INSTRUMENTATION_STRING = `bippy-${version}`;
@@ -16,9 +17,9 @@ const NO_OP = () => {
16
17
  /**/
17
18
  };
18
19
 
19
- const checkDCE = (fn: unknown): void => {
20
+ const checkDCE = (functionToCheck: unknown): void => {
20
21
  try {
21
- const code = Function.prototype.toString.call(fn);
22
+ const code = Function.prototype.toString.call(functionToCheck);
22
23
  if (code.indexOf("^_^") > -1) {
23
24
  setTimeout(() => {
24
25
  throw new Error(
@@ -52,27 +53,67 @@ export const isReactRefresh = (
52
53
  return Boolean(injectFnStr?.includes("(injected)"));
53
54
  };
54
55
 
55
- const onActiveListeners = new Set<() => unknown>();
56
+ export const _onActiveListeners = new Set<() => unknown>();
56
57
 
57
58
  export const _renderers = new Set<ReactRenderer>();
58
59
 
60
+ const rendererInjectListeners = new Set<(renderer: ReactRenderer) => void>();
61
+ // re-wrapping inject (e.g. after the hook is replaced) leaves the old
62
+ // wrapper in the call chain, so notifications are deduped per renderer
63
+ const notifiedRenderers = new WeakSet<ReactRenderer>();
64
+ let notifyingInject: ReactDevToolsGlobalHook["inject"] | null = null;
65
+
66
+ const ensureInjectNotifiesListeners = (rdtHook: ReactDevToolsGlobalHook): void => {
67
+ if (rdtHook.inject === notifyingInject) return;
68
+ const prevInject = rdtHook.inject;
69
+ const nextInject = (renderer: ReactRenderer) => {
70
+ const rendererId = prevInject.call(rdtHook, renderer);
71
+ if (!notifiedRenderers.has(renderer)) {
72
+ notifiedRenderers.add(renderer);
73
+ for (const listener of rendererInjectListeners) {
74
+ listener(renderer);
75
+ }
76
+ }
77
+ return rendererId;
78
+ };
79
+ rdtHook.inject = nextInject;
80
+ notifyingInject = nextInject;
81
+ };
82
+
83
+ /**
84
+ * Subscribes to future renderer injections into the DevTools hook. The
85
+ * single shared inject wrapper lets multiple consumers (override methods,
86
+ * react-refresh, user code) observe renderers without stacking patches
87
+ * whose restore order matters. Returns an unsubscribe function.
88
+ */
89
+ export const onRendererInject = (listener: (renderer: ReactRenderer) => void): Unsubscribe => {
90
+ ensureInjectNotifiesListeners(getRDTHook());
91
+ rendererInjectListeners.add(listener);
92
+ return toUnsubscribe(() => {
93
+ rendererInjectListeners.delete(listener);
94
+ });
95
+ };
96
+
59
97
  export const installRDTHook = (onActive?: () => unknown): ReactDevToolsGlobalHook => {
98
+ if (onActive) {
99
+ _onActiveListeners.add(onActive);
100
+ }
60
101
  const renderers = new Map<number, ReactRenderer>();
61
- let i = 0;
102
+ let rendererIdCounter = 0;
62
103
  let rdtHook: ReactDevToolsGlobalHook = {
63
104
  _instrumentationIsActive: false,
64
105
  _instrumentationSource: BIPPY_INSTRUMENTATION_STRING,
65
106
  checkDCE,
66
107
  hasUnsupportedRendererAttached: false,
67
108
  inject(renderer) {
68
- const nextID = ++i;
69
- renderers.set(nextID, renderer);
109
+ const nextRendererId = ++rendererIdCounter;
110
+ renderers.set(nextRendererId, renderer);
70
111
  _renderers.add(renderer);
71
112
  if (!rdtHook._instrumentationIsActive) {
72
113
  rdtHook._instrumentationIsActive = true;
73
- onActiveListeners.forEach((listener) => listener());
114
+ _onActiveListeners.forEach((listener) => listener());
74
115
  }
75
- return nextID;
116
+ return nextRendererId;
76
117
  },
77
118
  on: NO_OP,
78
119
  onCommitFiberRoot: NO_OP,
@@ -132,7 +173,7 @@ export const installRDTHook = (onActive?: () => unknown): ReactDevToolsGlobalHoo
132
173
 
133
174
  export const patchRDTHook = (onActive?: () => unknown): void => {
134
175
  if (onActive) {
135
- onActiveListeners.add(onActive);
176
+ _onActiveListeners.add(onActive);
136
177
  }
137
178
  try {
138
179
  const rdtHook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -151,7 +192,7 @@ export const patchRDTHook = (onActive?: () => unknown): void => {
151
192
  }
152
193
  if (rdtHook.renderers.size) {
153
194
  rdtHook._instrumentationIsActive = true;
154
- onActiveListeners.forEach((listener) => listener());
195
+ _onActiveListeners.forEach((listener) => listener());
155
196
  return;
156
197
  }
157
198
  const prevInject = rdtHook.inject;
@@ -160,24 +201,24 @@ export const patchRDTHook = (onActive?: () => unknown): void => {
160
201
  isReactRefreshOverride = true;
161
202
  // but since the underlying implementation doens't care,
162
203
  // it's ok: https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430
163
- const nextID = rdtHook.inject({
204
+ const injectedRendererId = rdtHook.inject({
164
205
  scheduleRefresh() {},
165
206
  } as unknown as ReactRenderer);
166
- if (nextID) {
207
+ if (injectedRendererId) {
167
208
  rdtHook._instrumentationIsActive = true;
168
209
  }
169
210
  }
170
211
  rdtHook.inject = (renderer) => {
171
- const id = prevInject(renderer);
212
+ const rendererId = prevInject(renderer);
172
213
  _renderers.add(renderer);
173
214
  if (isRefresh) {
174
215
  // react refresh doesn't inject this properly
175
216
  // https://github.com/facebook/react/blob/18eaf51bd51fed8dfed661d64c306759101d0bfd/packages/react-refresh/src/ReactFreshRuntime.js#L430
176
- rdtHook.renderers.set(id, renderer);
217
+ rdtHook.renderers.set(rendererId, renderer);
177
218
  }
178
219
  rdtHook._instrumentationIsActive = true;
179
- onActiveListeners.forEach((listener) => listener());
180
- return id;
220
+ _onActiveListeners.forEach((listener) => listener());
221
+ return rendererId;
181
222
  };
182
223
  }
183
224
  if (
@@ -0,0 +1,9 @@
1
+ export const HMR_RECONNECT_DELAY_MS = 1000;
2
+
3
+ export const PENDING_HOT_UPDATE_MAX_AGE_MS = 10_000;
4
+
5
+ export const VITE_WS_TOKEN_REGEX = /wsToken = "([^"]+)"/;
6
+
7
+ export const HMR_SOURCE_FILE_EXTENSION_REGEX = /\.(?:tsx|ts|jsx|js|mjs|cjs|css)$/;
8
+
9
+ export const BUNDLER_LAYER_PREFIX_REGEX = /^(?:\.\/)?\/?\([a-z][a-z0-9-]*\)\//;
@@ -0,0 +1,33 @@
1
+ import { isClientEnvironment } from "../rdt-hook.js";
2
+
3
+ import { createMetroHmrTransport } from "./metro-hmr-transport.js";
4
+ import { createNextWebpackHmrTransport } from "./next-webpack-hmr-transport.js";
5
+ import { HmrTransport, HmrUpdateHandler } from "./types.js";
6
+ import { createViteHmrTransport } from "./vite-hmr-transport.js";
7
+
8
+ /**
9
+ * Detects the dev server's HMR transport (Next.js webpack, then Metro for
10
+ * React Native, then Vite) and subscribes `onHmrUpdate` to hot updates.
11
+ * Resolves `null` on the server (SSR) and when no known transport is
12
+ * available (production builds, unsupported bundlers — Turbopack exposes
13
+ * `window.TURBOPACK_CHUNK_UPDATE_LISTENERS` but its update payload shape
14
+ * has not been validated, so it is not wired up yet).
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const transport = await detectHmrTransport((filePaths) => {
19
+ * console.log("hot updated:", filePaths);
20
+ * });
21
+ * transport?.dispose();
22
+ * ```
23
+ */
24
+ export const detectHmrTransport = async (
25
+ onHmrUpdate: HmrUpdateHandler,
26
+ ): Promise<HmrTransport | null> => {
27
+ if (!isClientEnvironment()) return null;
28
+ const webpackTransport = createNextWebpackHmrTransport(onHmrUpdate);
29
+ if (webpackTransport) return webpackTransport;
30
+ const metroTransport = createMetroHmrTransport(onHmrUpdate);
31
+ if (metroTransport) return metroTransport;
32
+ return createViteHmrTransport(onHmrUpdate);
33
+ };
@@ -0,0 +1,173 @@
1
+ import { getType, traverseFiber } from "../core.js";
2
+ import { getRDTHook, isClientEnvironment, onRendererInject } from "../rdt-hook.js";
3
+ import type { Fiber, FiberRoot, ReactRenderer } from "../types.js";
4
+ import { toUnsubscribe, type Unsubscribe } from "../unsubscribe.js";
5
+ import { PENDING_HOT_UPDATE_MAX_AGE_MS } from "./constants.js";
6
+ import { detectHmrTransport } from "./detect-hmr-transport.js";
7
+ import { HmrTransport } from "./types.js";
8
+
9
+ export interface ReactRefreshUpdate {
10
+ /**
11
+ * hot-updated source file paths reported by the bundler's HMR transport
12
+ * (auto-detected: Next.js webpack, Metro, Vite). Best-effort: empty when
13
+ * the bundler does not expose a transport (e.g. Turbopack) or when the
14
+ * transport message has not arrived yet (e.g. Metro delivers updates on
15
+ * an independent socket).
16
+ */
17
+ filePaths: string[];
18
+ root: FiberRoot;
19
+ /** new component types that were remounted, losing state */
20
+ staleComponents: unknown[];
21
+ /** mounted fibers whose component types were remounted */
22
+ staleFibers: Fiber[];
23
+ /** new component types that re-rendered preserving state */
24
+ updatedComponents: unknown[];
25
+ /** mounted fibers whose component types re-rendered preserving state */
26
+ updatedFibers: Fiber[];
27
+ }
28
+
29
+ export interface ReactRefreshHandler {
30
+ (update: ReactRefreshUpdate): void;
31
+ }
32
+
33
+ export interface ReactRefreshInstrumentationOptions {
34
+ onRefresh?: ReactRefreshHandler;
35
+ }
36
+
37
+ const collectFibersByComponentType = (root: FiberRoot, componentTypes: Set<unknown>): Fiber[] => {
38
+ if (componentTypes.size === 0 || !root.current) return [];
39
+ const matchedFibers: Fiber[] = [];
40
+ traverseFiber(root.current, (fiber) => {
41
+ // memo/forwardRef fibers carry the wrapper as fiber.type, while the
42
+ // refresh families can register either the wrapper or the inner type
43
+ if (componentTypes.has(fiber.type) || componentTypes.has(getType(fiber.type))) {
44
+ matchedFibers.push(fiber);
45
+ }
46
+ });
47
+ return matchedFibers;
48
+ };
49
+
50
+ const refreshHandlers = new Set<ReactRefreshHandler>();
51
+ const refreshWrappedRenderers = new WeakSet<ReactRenderer>();
52
+
53
+ let pendingFilePaths: string[] = [];
54
+ let pendingFilePathsReceivedAtMs = 0;
55
+
56
+ const bufferHotUpdateFilePaths = (filePaths: string[]): void => {
57
+ const nowMs = Date.now();
58
+ if (nowMs - pendingFilePathsReceivedAtMs > PENDING_HOT_UPDATE_MAX_AGE_MS) {
59
+ pendingFilePaths = [];
60
+ }
61
+ pendingFilePaths.push(...filePaths);
62
+ pendingFilePathsReceivedAtMs = nowMs;
63
+ };
64
+
65
+ const takeFreshFilePaths = (): string[] => {
66
+ if (Date.now() - pendingFilePathsReceivedAtMs > PENDING_HOT_UPDATE_MAX_AGE_MS) return [];
67
+ const freshFilePaths = [...pendingFilePaths];
68
+ // performReactRefresh calls scheduleRefresh synchronously once per
69
+ // mounted root, so clear the pending paths only after the whole
70
+ // refresh pass instead of on the first root
71
+ queueMicrotask(() => {
72
+ pendingFilePaths = [];
73
+ });
74
+ return freshFilePaths;
75
+ };
76
+
77
+ const wrapRendererScheduleRefresh = (renderer: ReactRenderer): void => {
78
+ if (refreshWrappedRenderers.has(renderer)) return;
79
+ const originalScheduleRefresh = renderer.scheduleRefresh;
80
+ if (typeof originalScheduleRefresh !== "function") return;
81
+ refreshWrappedRenderers.add(renderer);
82
+ renderer.scheduleRefresh = (root, update) => {
83
+ originalScheduleRefresh.call(renderer, root, update);
84
+ if (refreshHandlers.size === 0) return;
85
+ const staleComponents = Array.from(update.staleFamilies, (family) => family.current);
86
+ const updatedComponents = Array.from(update.updatedFamilies, (family) => family.current);
87
+ const refreshUpdate: ReactRefreshUpdate = {
88
+ filePaths: takeFreshFilePaths(),
89
+ root,
90
+ staleComponents,
91
+ staleFibers: collectFibersByComponentType(root, new Set(staleComponents)),
92
+ updatedComponents,
93
+ updatedFibers: collectFibersByComponentType(root, new Set(updatedComponents)),
94
+ };
95
+ for (const handler of refreshHandlers) {
96
+ handler(refreshUpdate);
97
+ }
98
+ };
99
+ };
100
+
101
+ let isRefreshWired = false;
102
+
103
+ const ensureRefreshWired = (): void => {
104
+ if (isRefreshWired) return;
105
+ isRefreshWired = true;
106
+ const rdtHook = getRDTHook();
107
+ for (const renderer of rdtHook.renderers.values()) {
108
+ wrapRendererScheduleRefresh(renderer);
109
+ }
110
+ onRendererInject(wrapRendererScheduleRefresh);
111
+ };
112
+
113
+ let activeTransport: HmrTransport | null = null;
114
+
115
+ // the bundler's HMR global may not exist yet when an early subscriber
116
+ // arrives, so detection is retried whenever the first subscriber (re)appears
117
+ const detectTransportForNewSubscriber = (): void => {
118
+ void detectHmrTransport(bufferHotUpdateFilePaths).then((detectedTransport) => {
119
+ if (!detectedTransport) return;
120
+ activeTransport?.dispose();
121
+ activeTransport = detectedTransport;
122
+ });
123
+ };
124
+
125
+ /**
126
+ * Subscribes to react-refresh (fast refresh) updates by wrapping
127
+ * `scheduleRefresh` on every renderer injected into the React DevTools
128
+ * global hook. The react-refresh runtime calls `scheduleRefresh` after each
129
+ * hot update, so this works with any bundler that uses react-refresh (Vite,
130
+ * Next.js webpack, Next.js Turbopack, Metro) without bundler-specific code.
131
+ * Returns an unsubscribe function (a no-op in non-client environments like
132
+ * SSR, so callers never need an environment check). The returned function
133
+ * is also a `Disposable`, so it composes with other bippy subscriptions
134
+ * through `using`.
135
+ *
136
+ * The bundler's HMR transport is auto-detected and each refresh update is
137
+ * augmented with the hot-updated source file paths it reported.
138
+ *
139
+ * The handler runs after React has re-rendered with the new component
140
+ * types, so the refreshed root's fiber tree already carries them;
141
+ * `updatedFibers`/`staleFibers` are the mounted fibers matching the
142
+ * hot-swapped component types.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * const unsubscribe = instrumentReactRefresh({
147
+ * onRefresh(update) {
148
+ * for (const fiber of update.updatedFibers) {
149
+ * console.log("hot updated:", getDisplayName(fiber.type));
150
+ * }
151
+ * console.log("changed files:", update.filePaths);
152
+ * },
153
+ * });
154
+ * unsubscribe();
155
+ * ```
156
+ *
157
+ * Pair with `getSource(fiber)` from `bippy/source` to symbolicate the
158
+ * source locations of `updatedFibers` when needed.
159
+ */
160
+ export const instrumentReactRefresh = (
161
+ options: ReactRefreshInstrumentationOptions,
162
+ ): Unsubscribe => {
163
+ const { onRefresh } = options;
164
+ if (!onRefresh || !isClientEnvironment()) return toUnsubscribe(() => {});
165
+ ensureRefreshWired();
166
+ if (refreshHandlers.size === 0) {
167
+ detectTransportForNewSubscriber();
168
+ }
169
+ refreshHandlers.add(onRefresh);
170
+ return toUnsubscribe(() => {
171
+ refreshHandlers.delete(onRefresh);
172
+ });
173
+ };
@@ -0,0 +1,188 @@
1
+ import { HMR_RECONNECT_DELAY_MS } from "./constants.js";
2
+ import { HmrTransport, HmrUpdateHandler } from "./types.js";
3
+
4
+ declare global {
5
+ var __turboModuleProxy: ((moduleName: string) => unknown) | undefined;
6
+ var nativeModuleProxy: Record<string, unknown> | undefined;
7
+ }
8
+
9
+ const getScriptUrlFromSourceCodeModule = (sourceCodeModule: unknown): string | null => {
10
+ if (typeof sourceCodeModule !== "object" || sourceCodeModule === null) return null;
11
+ if (!("getConstants" in sourceCodeModule)) return null;
12
+ const getConstants = sourceCodeModule.getConstants;
13
+ if (typeof getConstants !== "function") return null;
14
+ let constants: unknown;
15
+ try {
16
+ constants = getConstants.call(sourceCodeModule);
17
+ } catch {
18
+ return null;
19
+ }
20
+ if (typeof constants !== "object" || constants === null) return null;
21
+ if (!("scriptURL" in constants)) return null;
22
+ const scriptUrl = constants.scriptURL;
23
+ return typeof scriptUrl === "string" ? scriptUrl : null;
24
+ };
25
+
26
+ /**
27
+ * Resolves the URL the running React Native bundle was loaded from, using
28
+ * the same `SourceCode` native module React Native's own dev tooling reads
29
+ * (via the TurboModule proxy globals, so react-native is not imported).
30
+ * Returns `null` outside a Metro-served React Native runtime.
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * getMetroBundleUrl();
35
+ * // "http://localhost:8081/index.bundle?platform=ios&dev=true"
36
+ * ```
37
+ */
38
+ export const getMetroBundleUrl = (): string | null => {
39
+ if (typeof globalThis.__turboModuleProxy === "function") {
40
+ let sourceCodeModule: unknown;
41
+ try {
42
+ sourceCodeModule = globalThis.__turboModuleProxy("SourceCode");
43
+ } catch {
44
+ sourceCodeModule = null;
45
+ }
46
+ const scriptUrl = getScriptUrlFromSourceCodeModule(sourceCodeModule);
47
+ if (scriptUrl) return scriptUrl;
48
+ }
49
+ const legacySourceCodeModule = globalThis.nativeModuleProxy?.SourceCode;
50
+ return getScriptUrlFromSourceCodeModule(legacySourceCodeModule);
51
+ };
52
+
53
+ // HACK: module sourceURLs may be JSC-safe URLs where "//&" stands in for
54
+ // "?" (iOS 16.4 stack traces strip query strings), so the query separator
55
+ // must be normalized before URL parsing.
56
+ const normalizeJscSafeUrl = (jscSafeUrl: string): string => jscSafeUrl.replace("//&", "?");
57
+
58
+ const getSourcePathFromSourceUrl = (sourceUrl: string): string | null => {
59
+ let parsedUrl: URL;
60
+ try {
61
+ parsedUrl = new URL(normalizeJscSafeUrl(sourceUrl));
62
+ } catch {
63
+ return null;
64
+ }
65
+ let sourcePath = decodeURIComponent(parsedUrl.pathname);
66
+ if (sourcePath.startsWith("/")) sourcePath = sourcePath.slice(1);
67
+ // Metro rewrites each module's real extension to ".bundle" when building
68
+ // hot-update sourceURLs, so the original extension is unrecoverable.
69
+ if (sourcePath.endsWith(".bundle")) sourcePath = sourcePath.slice(0, -".bundle".length);
70
+ return sourcePath.length > 0 ? sourcePath : null;
71
+ };
72
+
73
+ const collectModuleSourcePaths = (hmrModules: unknown, filePaths: string[]) => {
74
+ if (!Array.isArray(hmrModules)) return;
75
+ for (const hmrModule of hmrModules) {
76
+ if (typeof hmrModule !== "object" || hmrModule === null) continue;
77
+ if (!("sourceURL" in hmrModule) || typeof hmrModule.sourceURL !== "string") continue;
78
+ const sourcePath = getSourcePathFromSourceUrl(hmrModule.sourceURL);
79
+ if (!sourcePath || sourcePath.includes("node_modules")) continue;
80
+ filePaths.push(sourcePath);
81
+ }
82
+ };
83
+
84
+ /**
85
+ * Extracts the updated source file paths from a raw Metro HMR WebSocket
86
+ * message. Paths are project-relative but extension-less (`src/app`, not
87
+ * `src/app.tsx`) because Metro rewrites module extensions to `.bundle`.
88
+ * The initial update replayed on connect is skipped. Returns an empty
89
+ * array for any other message shape.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * parseMetroUpdatePaths(rawMessageData);
94
+ * // ["src/app"]
95
+ * ```
96
+ */
97
+ export const parseMetroUpdatePaths = (rawMessageData: string): string[] => {
98
+ let message: unknown;
99
+ try {
100
+ message = JSON.parse(rawMessageData);
101
+ } catch {
102
+ return [];
103
+ }
104
+ if (typeof message !== "object" || message === null) return [];
105
+ if (!("type" in message) || message.type !== "update") return [];
106
+ if (!("body" in message) || typeof message.body !== "object" || message.body === null) return [];
107
+ const updateBody = message.body;
108
+ if ("isInitialUpdate" in updateBody && updateBody.isInitialUpdate === true) return [];
109
+ const filePaths: string[] = [];
110
+ if ("added" in updateBody) collectModuleSourcePaths(updateBody.added, filePaths);
111
+ if ("modified" in updateBody) collectModuleSourcePaths(updateBody.modified, filePaths);
112
+ return filePaths;
113
+ };
114
+
115
+ export interface MetroHmrTransportOptions {
116
+ bundleUrl?: string;
117
+ }
118
+
119
+ /**
120
+ * Subscribes to the Metro dev server's `/hot` HMR WebSocket (as a second
121
+ * client alongside React Native's own) and invokes `onHmrUpdate` with the
122
+ * updated file paths on every hot update. Reconnects automatically when
123
+ * the dev server restarts. Returns `null` when no Metro bundle URL can be
124
+ * resolved (production builds, non-Metro runtimes).
125
+ *
126
+ * @example
127
+ * ```ts
128
+ * const transport = createMetroHmrTransport((filePaths) => {
129
+ * console.log("hot updated:", filePaths);
130
+ * });
131
+ * transport?.dispose();
132
+ * ```
133
+ */
134
+ export const createMetroHmrTransport = (
135
+ onHmrUpdate: HmrUpdateHandler,
136
+ options: MetroHmrTransportOptions = {},
137
+ ): HmrTransport | null => {
138
+ if (typeof WebSocket === "undefined") return null;
139
+ const bundleUrl = options.bundleUrl ?? getMetroBundleUrl();
140
+ if (!bundleUrl) return null;
141
+
142
+ let hotSocketUrl: string;
143
+ try {
144
+ const parsedBundleUrl = new URL(bundleUrl);
145
+ const socketProtocol = parsedBundleUrl.protocol === "https:" ? "wss" : "ws";
146
+ hotSocketUrl = `${socketProtocol}://${parsedBundleUrl.host}/hot`;
147
+ } catch {
148
+ return null;
149
+ }
150
+
151
+ let isDisposed = false;
152
+ let socket: WebSocket | null = null;
153
+ let reconnectTimerId: ReturnType<typeof setTimeout> | undefined;
154
+
155
+ const scheduleReconnect = () => {
156
+ if (isDisposed) return;
157
+ reconnectTimerId = setTimeout(connect, HMR_RECONNECT_DELAY_MS);
158
+ };
159
+
160
+ const connect = () => {
161
+ if (isDisposed) return;
162
+ const connectedSocket = new WebSocket(hotSocketUrl);
163
+ socket = connectedSocket;
164
+ connectedSocket.onopen = () => {
165
+ connectedSocket.send(
166
+ JSON.stringify({ type: "register-entrypoints", entryPoints: [bundleUrl] }),
167
+ );
168
+ };
169
+ connectedSocket.onmessage = (event) => {
170
+ const filePaths = parseMetroUpdatePaths(String(event.data));
171
+ if (filePaths.length > 0) onHmrUpdate(filePaths);
172
+ };
173
+ connectedSocket.onclose = scheduleReconnect;
174
+ };
175
+
176
+ connect();
177
+
178
+ return {
179
+ dispose: () => {
180
+ isDisposed = true;
181
+ clearTimeout(reconnectTimerId);
182
+ if (socket) {
183
+ socket.onclose = null;
184
+ socket.close();
185
+ }
186
+ },
187
+ };
188
+ };
@@ -0,0 +1,72 @@
1
+ import { HMR_SOURCE_FILE_EXTENSION_REGEX } from "./constants.js";
2
+ import { normalizeHmrFilePath } from "./normalize-hmr-file-path.js";
3
+ import { HmrTransport, HmrUpdateHandler } from "./types.js";
4
+
5
+ interface WebpackHotUpdateGlobal {
6
+ (chunkId: unknown, updatedModules: Record<string, unknown> | undefined, runtime: unknown): void;
7
+ }
8
+
9
+ declare global {
10
+ interface Window {
11
+ webpackHotUpdate_N_E?: WebpackHotUpdateGlobal;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Normalizes webpack hot-update module keys into project-relative source
17
+ * file paths, dropping node_modules entries and non-source keys (e.g.
18
+ * webpack runtime helpers).
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * normalizeWebpackModulePaths(["(app-pages-browser)/./app/page.tsx"]);
23
+ * // ["app/page.tsx"]
24
+ * ```
25
+ */
26
+ export const normalizeWebpackModulePaths = (moduleKeys: string[]): string[] => {
27
+ const filePaths: string[] = [];
28
+ for (const moduleKey of moduleKeys) {
29
+ if (moduleKey.includes("node_modules")) continue;
30
+ const filePath = normalizeHmrFilePath(moduleKey);
31
+ if (!HMR_SOURCE_FILE_EXTENSION_REGEX.test(filePath)) continue;
32
+ filePaths.push(filePath);
33
+ }
34
+ return filePaths;
35
+ };
36
+
37
+ /**
38
+ * Subscribes to Next.js webpack hot updates by wrapping the
39
+ * `webpackHotUpdate_N_E` global and invokes `onHmrUpdate` with the updated
40
+ * file paths. Returns `null` when the page is not a Next.js webpack dev
41
+ * build.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const transport = createNextWebpackHmrTransport((filePaths) => {
46
+ * console.log("hot updated:", filePaths);
47
+ * });
48
+ * transport?.dispose();
49
+ * ```
50
+ */
51
+ export const createNextWebpackHmrTransport = (
52
+ onHmrUpdate: HmrUpdateHandler,
53
+ ): HmrTransport | null => {
54
+ if (typeof window === "undefined") return null;
55
+ const originalHotUpdate = window.webpackHotUpdate_N_E;
56
+ if (typeof originalHotUpdate !== "function") return null;
57
+
58
+ const wrappedHotUpdate: WebpackHotUpdateGlobal = (chunkId, updatedModules, runtime) => {
59
+ const filePaths = normalizeWebpackModulePaths(Object.keys(updatedModules ?? {}));
60
+ if (filePaths.length > 0) onHmrUpdate(filePaths);
61
+ originalHotUpdate(chunkId, updatedModules, runtime);
62
+ };
63
+ window.webpackHotUpdate_N_E = wrappedHotUpdate;
64
+
65
+ return {
66
+ dispose: () => {
67
+ if (window.webpackHotUpdate_N_E === wrappedHotUpdate) {
68
+ window.webpackHotUpdate_N_E = originalHotUpdate;
69
+ }
70
+ },
71
+ };
72
+ };
@@ -0,0 +1,24 @@
1
+ import { normalizeFileName } from "../source/get-source.js";
2
+
3
+ import { BUNDLER_LAYER_PREFIX_REGEX } from "./constants.js";
4
+
5
+ /**
6
+ * Normalizes a bundler module key or HMR update path into a plain,
7
+ * project-relative file path. Strips URL schemes (via
8
+ * {@link normalizeFileName}), bundler layer prefixes like
9
+ * `(app-pages-browser)/`, and leading `./` segments.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * normalizeHmrFilePath("(app-pages-browser)/./app/page.tsx");
14
+ * // "app/page.tsx"
15
+ * ```
16
+ */
17
+ export const normalizeHmrFilePath = (filePath: string): string => {
18
+ let normalizedFilePath = normalizeFileName(filePath);
19
+ normalizedFilePath = normalizedFilePath.replace(BUNDLER_LAYER_PREFIX_REGEX, "");
20
+ if (normalizedFilePath.startsWith("./")) {
21
+ normalizedFilePath = normalizedFilePath.slice(2);
22
+ }
23
+ return normalizedFilePath;
24
+ };
@@ -0,0 +1,7 @@
1
+ export interface HmrUpdateHandler {
2
+ (filePaths: string[]): void;
3
+ }
4
+
5
+ export interface HmrTransport {
6
+ dispose: () => void;
7
+ }