Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/9763-websocket-server-upgrades.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- Attach native `WebSocketServer({ server })` instances to an existing HTTP listener; deliver manual `handleUpgrade` callbacks and connection events with usable client handles and the original request.
- Bind `WebSocketServer({ port: 0 })` to an ephemeral port and expose the actual listening address through `address()`.
- Treat native handle IDs as identities when hashing Sets, including `WebSocketServer.clients`.
2 changes: 2 additions & 0 deletions crates/perry-api-manifest/src/entries/part_1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,8 @@ pub(crate) const API_MANIFEST_PART_1: &[ApiEntry] = &[
// #1113 — `wss.handleUpgrade(req, socket, head, cb)` for a
// `new WebSocketServer({ noServer: true })`.
method("ws", "handleUpgrade", true, None),
method("ws", "address", true, None),
method("ws", "emit", true, None),
// Issue #577 Phase 4 — Client-class methods for the upgrade-path wsId.
method("ws", "on", true, Some("Client")),
method("ws", "addListener", true, Some("Client")),
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-codegen/src/ext_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,8 @@ const FFI_REGISTRY: &[(&str, OwnerKind)] = &[
("js_ws_close_client", OwnerKind::WellKnown("ws")),
("js_ws_server_new", OwnerKind::WellKnown("ws")),
("js_ws_server_clients", OwnerKind::WellKnown("ws")),
("js_ws_server_address", OwnerKind::WellKnown("ws")),
("js_ws_server_emit", OwnerKind::WellKnown("ws")),
("js_ws_server_close", OwnerKind::WellKnown("ws")),

// ── #1724: global Blob/File + URL object-URL helpers ──────────────
Expand Down
27 changes: 27 additions & 0 deletions crates/perry-codegen/src/lower_call/native_table/ws_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,33 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "ws",
has_receiver: true,
method: "handleUpgrade",
class_filter: None,
runtime: "js_ws_handle_upgrade",
args: &[NA_F64, NA_F64, NA_F64, NA_PTR],
ret: NR_VOID,
},
NativeModSig {
module: "ws",
has_receiver: true,
method: "address",
class_filter: None,
runtime: "js_ws_server_address",
args: &[],
ret: NR_F64,
},
NativeModSig {
module: "ws",
has_receiver: true,
method: "emit",
class_filter: None,
runtime: "js_ws_server_emit",
args: &[NA_STR, NA_F64, NA_F64],
ret: NR_BOOL,
},
// Issue #577 Phase 4 — `("ws", "Client")` instance methods.
// The wsId delivered to `Server.on('upgrade', (req, wsId, head) => …)`
// is NaN-boxed POINTER_TAG so unbox_to_i64 (called by the dispatch
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-codegen/src/runtime_decls/stdlib_ffi/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ pub(crate) fn declare_web(module: &mut LlModule) {
module.declare_function("js_ws_on_client_i64", I64, &[I64, I64, I64]);
module.declare_function("js_ws_server_close", VOID, &[I64]);
module.declare_function("js_ws_server_clients", DOUBLE, &[I64]);
module.declare_function("js_ws_server_address", DOUBLE, &[I64]);
module.declare_function("js_ws_server_emit", I32, &[I64, I64, DOUBLE, DOUBLE]);
module.declare_function("js_ws_server_new", I64, &[DOUBLE]);
// #1113 — `wss.handleUpgrade(req, socket, head, cb)`. Receiver
// (the noServer WsServerHandle) is passed as I64 (post-unbox_to_i64
Expand All @@ -138,7 +140,7 @@ pub(crate) fn declare_web(module: &mut LlModule) {
// cb is the unboxed closure pointer (I64).
module.declare_function(
"js_ws_handle_upgrade",
I64,
VOID,
&[I64, DOUBLE, DOUBLE, DOUBLE, I64],
);
module.declare_function("js_ws_wait_for_message", I64, &[I64, DOUBLE]);
Expand Down
1 change: 1 addition & 0 deletions crates/perry-ext-http/src/server/dispatch_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ extern "C" {
pub(crate) fn ensure_dispatch_extensions_registered() {
static REGISTER: Once = Once::new();
REGISTER.call_once(|| unsafe {
perry_ext_ws::register_http_address_reader(crate::server::upgrade::attached_address);
js_register_handle_method_dispatch_extension(http_server_method_dispatch_ext);
js_register_handle_property_dispatch_extension(http_server_property_dispatch_ext);
js_register_handle_property_set_dispatch_extension(http_server_property_set_dispatch_ext);
Expand Down
9 changes: 8 additions & 1 deletion crates/perry-ext-http/src/server/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1220,7 +1220,9 @@ async fn handle_request(
let has_upgrade_listeners = get_handle::<HttpServer>(server_handle)
.map(|server| server_has_event_listener(server, "upgrade"))
.unwrap_or(false);
if has_upgrade_listeners && req.headers().contains_key("sec-websocket-key") {
if (has_upgrade_listeners || perry_ext_ws::has_attached_server(server_handle))
&& req.headers().contains_key("sec-websocket-key")
{
return handle_websocket_upgrade(
server_handle,
peer,
Expand Down Expand Up @@ -1582,6 +1584,11 @@ pub extern "C" fn js_node_http_server_process_pending() -> i32 {
up.head,
);
} else {
perry_ext_ws::accept_attached_connection(
up.server_handle,
handle_to_pointer_f64(up.request_handle),
up.ws_id,
);
crate::server::upgrade::fire_upgrade_listeners(
up.server_handle,
up.request_handle,
Expand Down
1 change: 1 addition & 0 deletions crates/perry-ext-http/src/server/server/deferred_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ where
// #8082: the drained snapshot crosses each callback — root it.
let scope = perry_ffi::TransientRootScope::enter();
let rooted = scope.root_addrs(&cbs);
perry_ext_ws::attached_server_listening(server_handle);
let mut call = DeferredCallbacksCall {
callbacks: rooted.as_ptr(),
len: rooted.len(),
Expand Down
29 changes: 17 additions & 12 deletions crates/perry-ext-http/src/server/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,7 @@
//! so user code can interact with it through `ws.on('message',…)`,
//! `ws.send(…)`, `ws.close(…)` unchanged.
//!
//! The TS-side wrapper for `import { WebSocketServer } from 'ws'`
//! when constructed with `{ server }` simply registers an
//! `'upgrade'` listener that re-dispatches to its own `'connection'`
//! event:
//!
//! ```ts
//! const wss = new WebSocketServer({ server: httpServer });
//! // wss internally:
//! // server.on('upgrade', (req, wsId, head) => {
//! // wss.emit('connection', wsId, req);
//! // });
//! ```
//! Attached WebSocket servers are native observers registered by perry-ext-ws.

use perry_ffi::{alloc_string, get_handle_mut, JsClosure, RawClosureHeader};

Expand Down Expand Up @@ -122,3 +111,19 @@ pub(crate) fn fire_upgrade_listeners(
fn _force_link() -> u64 {
POINTER_TAG | (PTR_MASK & 0)
}

/// Read owned address metadata without allocating JS objects or introducing a
/// reverse dependency from ws to HTTP.
pub(crate) fn attached_address(handle: i64) -> Option<(String, u16)> {
perry_ffi::get_handle::<HttpServer>(handle)
.and_then(|s| s.listening.then(|| (s.bound_host.clone(), s.bound_port)))
.or_else(|| {
perry_ffi::get_handle::<crate::server::https_server::HttpsServer>(handle).and_then(
|s| {
s.base
.listening
.then(|| (s.base.bound_host.clone(), s.base.bound_port))
},
)
})
}
6 changes: 6 additions & 0 deletions crates/perry-ext-http/src/test_async_shims.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,9 @@ pub extern "C" fn perry_ffi_spawn_blocking_with_reactor(
// host stdlib archive, which unit-test binaries do not link.
#[no_mangle]
pub extern "C" fn perry_ffi_spawn_async(_ctx: *mut c_void) {}

// Linking the ws dispatch extension also retains its synchronous polling
// helper. These unit tests use the no-op task shim above; real networking is
// exercised by the compiled HTTP/WebSocket integration tests.
#[no_mangle]
pub extern "C" fn perry_ffi_run_pending(_budget_ms: u64) {}
126 changes: 126 additions & 0 deletions crates/perry-ext-ws/src/dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Runtime dispatch for WebSocket receivers whose static type was erased.
use super::*;

extern "C" {
fn js_register_handle_method_dispatch_extension(
f: unsafe extern "C" fn(i64, *const u8, usize, *const f64, usize, *mut f64) -> i32,
);
fn js_class_method_bind(receiver: f64, name: *const u8, len: usize) -> f64;
}

pub(super) unsafe fn register_method_dispatch() {
js_register_handle_method_dispatch_extension(method);
}

fn knows(handle: i64, name: &str) -> bool {
if get_handle_mut::<WsServerHandle>(handle).is_some() {
matches!(
name,
"clients" | "address" | "handleUpgrade" | "emit" | "on" | "addListener" | "close"
)
} else if get_handle_mut::<WsClientHandle>(handle).is_some() {
matches!(name, "send" | "close" | "on" | "addListener" | "readyState")
} else {
false
}
}

pub(super) unsafe fn property(handle: i64, ptr: *const u8, len: usize, out: *mut f64) -> i32 {
if ptr.is_null() {
return 0;
}
let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) else {
return 0;
};
if !knows(handle, name) {
return 0;
}
let value = match name {
"clients" => js_ws_server_clients(handle),
"readyState" => js_ws_ready_state(handle),
_ => js_class_method_bind(f64::from_bits(POINTER_TAG | handle as u64), ptr, len),
};
if !out.is_null() {
*out = value;
}
1
}

unsafe extern "C" fn method(
handle: i64,
ptr: *const u8,
len: usize,
args: *const f64,
argc: usize,
out: *mut f64,
) -> i32 {
if ptr.is_null() {
return 0;
}
let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) else {
return 0;
};
if !knows(handle, name) || matches!(name, "clients" | "readyState") {
return 0;
}
let args = if args.is_null() {
&[][..]
} else {
std::slice::from_raw_parts(args, argc)
};
let scope = perry_ffi::TransientRootScope::enter();
let args: Vec<_> = args.iter().map(|value| scope.root_nanbox(*value)).collect();
let arg = |i| {
args.get(i)
.map(|value: &perry_ffi::TransientRootedNanbox| value.get())
.unwrap_or_else(undefined)
};
let value = match name {
"address" => js_ws_server_address(handle),
"handleUpgrade" => {
js_ws_handle_upgrade(
handle,
arg(0),
arg(1),
arg(2),
(arg(3).to_bits() & POINTER_MASK) as i64,
);
undefined()
}
"emit" => {
let event = string_arg(arg(0));
f64::from_bits(
JsValue::from_bool(js_ws_server_emit(handle, event, arg(1), arg(2)) != 0).bits(),
)
}
"on" | "addListener" => {
let event = string_arg(arg(0));
js_ws_on(handle, event, (arg(1).to_bits() & POINTER_MASK) as i64);
f64::from_bits(POINTER_TAG | handle as u64)
}
"send" => {
js_ws_send(handle, string_arg(arg(0)));
undefined()
}
"close" => {
js_ws_close(handle);
undefined()
}
_ => return 0,
};
if !out.is_null() {
*out = value;
}
1
}

fn string_arg(value: f64) -> *const StringHeader {
let value = JsValue::from_bits(value.to_bits());
if value.is_short_string() {
value_string(value)
.map(|s| alloc_string(&s).as_raw() as *const StringHeader)
.unwrap_or(std::ptr::null())
} else {
value.as_string_ptr()
}
}
Loading
Loading