Skip to content

Commit 31557c3

Browse files
authored
feat(wasm): in-browser hmr (#2533)
1 parent 6a04cdb commit 31557c3

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+15660
-15458
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { initHMR } from "./client-messageport";
2+
3+
initHMR();
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// MessagePort-based HMR client for utoo-web (browser/wasm environment)
2+
// This file is injected instead of client.ts when building for wasm target
3+
4+
// @ts-ignore
5+
import { connect } from "@vercel/turbopack-ecmascript-runtime/browser/dev/hmr-client/hmr-client";
6+
import { addMessageListener, connectHMR, sendMessage } from "./messageport";
7+
8+
export function initHMR() {
9+
// First register the HMR client listeners
10+
connect({
11+
addMessageListener,
12+
sendMessage,
13+
onUpdateError: console.error,
14+
});
15+
16+
// Then start listening for MessagePort connection from parent window
17+
// The actual "turbopack-connected" event will be dispatched when
18+
// the parent window sends the "hmr-connect" message with the port
19+
connectHMR();
20+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// MessagePort-based HMR connection for browser environments (utoo-web)
2+
// This replaces WebSocket when running in an iframe with HmrServer
3+
4+
type WebSocketMessage =
5+
| {
6+
type: "turbopack-connected";
7+
}
8+
| {
9+
type: "turbopack-message";
10+
data: Record<string, any>;
11+
};
12+
13+
let port: MessagePort | null = null;
14+
let eventCallbacks: Array<(event: WebSocketMessage) => void> = [];
15+
16+
// Helper function to dispatch messages to all event callbacks
17+
function dispatchMessage(message: WebSocketMessage) {
18+
for (const eventCallback of eventCallbacks) {
19+
eventCallback(message);
20+
}
21+
}
22+
23+
export function addMessageListener(
24+
callback: (event: WebSocketMessage) => void,
25+
) {
26+
eventCallbacks.push(callback);
27+
}
28+
29+
export function sendMessage(data: any) {
30+
if (port) {
31+
const message = typeof data === "string" ? data : JSON.stringify(data);
32+
port.postMessage(message);
33+
}
34+
}
35+
36+
export interface HMROptions {
37+
// Not used for MessagePort, kept for API compatibility
38+
path?: string;
39+
}
40+
41+
let reloading = false;
42+
let serverSessionId: number | null = null;
43+
44+
function handleMessage(event: MessageEvent<string>) {
45+
if (reloading) {
46+
return;
47+
}
48+
49+
try {
50+
const msg =
51+
typeof event.data === "string" ? JSON.parse(event.data) : event.data;
52+
53+
// Handle the different message formats from HmrServer
54+
if (msg.action === "turbopack-connected") {
55+
if (serverSessionId !== null && serverSessionId !== msg.data.sessionId) {
56+
window.location.reload();
57+
reloading = true;
58+
return;
59+
}
60+
61+
serverSessionId = msg.data.sessionId;
62+
63+
// Convert to turbopack format and trigger handleSocketConnected
64+
const connected: WebSocketMessage = { type: "turbopack-connected" };
65+
dispatchMessage(connected);
66+
return;
67+
}
68+
69+
if (msg.action === "reload") {
70+
window.location.reload();
71+
reloading = true;
72+
return;
73+
}
74+
75+
if (msg.action === "turbopack-message") {
76+
const turbopackMessage: WebSocketMessage = {
77+
type: "turbopack-message",
78+
data: msg.data,
79+
};
80+
dispatchMessage(turbopackMessage);
81+
return;
82+
}
83+
84+
// Handle direct turbopack-dev-server messages
85+
if (
86+
msg.type &&
87+
["partial", "restart", "notFound", "issues"].includes(msg.type)
88+
) {
89+
const turbopackMessage: WebSocketMessage = {
90+
type: "turbopack-message",
91+
data: msg,
92+
};
93+
dispatchMessage(turbopackMessage);
94+
return;
95+
}
96+
97+
// TODO: handle rest msg.actions
98+
} catch (e) {
99+
console.error("[HMR] Failed to parse message:", e);
100+
}
101+
}
102+
103+
/**
104+
* Connect to HMR server via MessagePort.
105+
* The iframe sends "hmr-ready" to parent, and parent responds with "hmr-connect"
106+
* containing the MessagePort for bidirectional communication.
107+
*/
108+
export function connectHMR(_options?: HMROptions) {
109+
if (typeof window === "undefined") {
110+
return;
111+
}
112+
113+
console.log("[HMR] waiting for MessagePort connection...");
114+
115+
window.addEventListener("message", (event) => {
116+
// Check if this is an HMR connect message with a port
117+
if (event.data?.type === "hmr-connect" && event.ports.length > 0) {
118+
if (port) {
119+
// Already connected, close previous port
120+
port.close();
121+
}
122+
123+
port = event.ports[0];
124+
port.onmessage = handleMessage;
125+
port.onmessageerror = () => {
126+
console.error("[HMR] MessagePort error");
127+
port = null;
128+
};
129+
130+
console.log("[HMR] connected via MessagePort");
131+
132+
// Don't dispatch connected event here - the server will send
133+
// a "turbopack-connected" message through the port which will
134+
// trigger the connected event via handleMessage
135+
}
136+
});
137+
138+
// Signal to parent that HMR client is ready
139+
if (window.parent && window.parent !== window) {
140+
window.parent.postMessage({ type: "hmr-ready" }, "*");
141+
console.log("[HMR] sent hmr-ready to parent");
142+
}
143+
}
144+
145+
/**
146+
* Check if HMR is connected
147+
*/
148+
export function isConnected(): boolean {
149+
return port !== null;
150+
}
151+
152+
/**
153+
* Disconnect HMR
154+
*/
155+
export function disconnect() {
156+
if (port) {
157+
port.close();
158+
port = null;
159+
}
160+
eventCallbacks = [];
161+
serverSessionId = null;
162+
reloading = false;
163+
}

crates/pack-core/js/src/hmr/websocket.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export function connectHMR(options: HMROptions) {
146146

147147
let url = `${protocol}://${hostname}:${port}`;
148148

149-
source = new window.WebSocket(`${url}${options.path}`);
149+
source = new WebSocket(`${url}${options.path}`);
150150
source.onopen = handleOnline;
151151
source.onerror = handleDisconnect;
152152
source.onmessage = handleMessage;

crates/pack-core/src/client/context.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,14 @@ pub async fn get_client_runtime_entries(
227227
}
228228

229229
if is_development && watch && hot {
230+
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
231+
let hmr_bootstrap_path = rcstr!("hmr/bootstrap-messageport.ts");
232+
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
233+
let hmr_bootstrap_path = rcstr!("hmr/bootstrap.ts");
234+
230235
runtime_entries.push(
231236
RuntimeEntry::Source(ResolvedVc::upcast(
232-
FileSource::new(embed_file_path(rcstr!("hmr/bootstrap.ts")).owned().await?)
237+
FileSource::new(embed_file_path(hmr_bootstrap_path).owned().await?)
233238
.to_resolved()
234239
.await?,
235240
))

crates/utoo-wasm/src/deps.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! WASM-compatible dependency resolution module.
1+
//! Dependency resolution module.
22
//!
33
//! Uses ruborist's unified `build_deps` API with OPFS file system.
44
@@ -11,7 +11,7 @@ use utoo_ruborist::service::{build_deps, BuildDepsOptions};
1111

1212
use crate::fs::OpfsGlob;
1313

14-
/// Default registry URL for WASM environment.
14+
/// Default registry URL.
1515
const DEFAULT_REGISTRY: &str = "https://registry.npmmirror.com";
1616

1717
/// Default concurrency for browser environment.

crates/utoo-wasm/src/fs.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! OPFS filesystem API and Glob implementation for WASM environment.
1+
//! OPFS filesystem API and Glob implementation.
22
//!
33
//! Provides filesystem operations and implements ruborist's `Glob` trait using `opfs_project` bindings.
44
@@ -11,18 +11,15 @@ use utoo_ruborist::service::Glob;
1111
use wasm_bindgen::prelude::*;
1212

1313
use crate::errors::to_js_error;
14-
use crate::tokio_runtime::TOKIO_RUNTIME;
14+
use crate::tokio_runtime::runtime;
1515

1616
fn block_on<T>(fut: impl std::future::Future<Output = T> + Send + 'static) -> Result<T, JsError>
1717
where
1818
T: Send + 'static,
1919
{
2020
let (sender, receiver) = oneshot::channel();
21-
let rt = TOKIO_RUNTIME
22-
.get()
23-
.ok_or_else(|| JsError::new("tokio runtime not initialized"))?;
2421

25-
rt.spawn(async move {
22+
runtime().spawn(async move {
2623
let _ = sender.send(fut.await);
2724
});
2825

@@ -31,7 +28,7 @@ where
3128
.map_err(|e| JsError::new(&format!("Recv error: {}", e)))
3229
}
3330

34-
/// OPFS-backed glob for WASM environment.
31+
/// OPFS-backed glob implementation.
3532
#[derive(Debug, Clone, Copy, Default)]
3633
pub struct OpfsGlob;
3734

crates/utoo-wasm/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub(crate) mod errors;
2929
mod fs;
3030
#[cfg(feature = "utoopack")]
3131
mod opfs_offload;
32+
mod pm;
3233
mod project;
3334
mod wasm_shim;
3435

@@ -67,6 +68,7 @@ pub fn init_log_filter(mut filter: String) {
6768
let fmt_layer = fmt::layer()
6869
.without_time()
6970
.with_span_events(fmt::format::FmtSpan::NONE)
71+
// .with_span_events(fmt::format::FmtSpan::CLOSE)
7072
.with_writer(MakeWebConsoleWriter::new())
7173
.with_filter(EnvFilter::new(filter));
7274

0 commit comments

Comments
 (0)