Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
46e55d5
fix request/response cloning and body handling
noise64 Jul 21, 2026
315ac2d
support the textdecoder fatal option
noise64 Jul 22, 2026
713ac85
support binary and blob websocket sends
noise64 Jul 22, 2026
e96a731
update dts goldenfiles for new example exports
noise64 Jul 22, 2026
69767ee
fix binary body handling in dgram, websocketstream, and http shims
noise64 Jul 22, 2026
0cc6efd
handle all typed array views in fetch bodies
noise64 Jul 22, 2026
e3dfb17
cancel the underlying http request when a fetch signal aborts
noise64 Jul 22, 2026
5552e5c
describe the websocket binary send test without issue references
noise64 Jul 22, 2026
5e32971
apply rustfmt to test sources
noise64 Jul 22, 2026
ee4e0fb
keep the websocket mock building against the golem wasmtime fork
noise64 Jul 22, 2026
97c68ff
update the fetch dts goldenfile for the new export
noise64 Jul 23, 2026
7516b21
copy only a view's own bytes when draining a body stream, and report …
noise64 Jul 23, 2026
5dfa3cd
abort the fetch only once the server confirms the request arrived
noise64 Jul 23, 2026
7625552
keep the dgram send callback tests as known gaps, udp still hangs in …
noise64 Jul 23, 2026
aa39cdd
report intl as available in the node compat shim, the runtime ships it
noise64 Jul 23, 2026
e22ff8f
mark the url tests needing idna domain conversion as known gaps
noise64 Jul 23, 2026
47f7d4a
regenerate the node compat report and correct its generator script
noise64 Jul 23, 2026
ba79a60
serialize websocket sends so an async blob keeps its place in line
noise64 Jul 24, 2026
d819309
cancel the request-body upload on fetch abort, and reject cloning a u…
noise64 Jul 24, 2026
5e1e1f6
cover the websocketstream sink blob send with a runtime test
noise64 Jul 24, 2026
82bd2ca
release intermediate redirect bodies, and cover fetch abort across re…
noise64 Jul 24, 2026
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
15 changes: 14 additions & 1 deletion crates/wasm-rquickjs/skeleton/src/builtin/dgram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,10 +354,23 @@ impl DgramSocket {
pub async fn send(
&self,
ctx: Ctx<'_>,
data: Vec<u8>,
data: rquickjs::TypedArray<'_, u8>,
addr: Option<String>,
port: Option<u32>,
) -> rquickjs::Result<u32> {
// Accept a typed array (the JS side passes a Uint8Array). rquickjs cannot
// convert a Uint8Array to `Vec<u8>` — that expects a plain JS Array — so a
// `Vec<u8>` parameter rejects every datagram with "Error converting from js
// 'object' into type 'array'". Copy the bytes out, mirroring the WebSocket
// and http shims. A detached buffer has no bytes to send, so report it
// rather than silently transmitting an empty datagram.
let data = data
.as_bytes()
.ok_or_else(|| {
Exception::throw_message(&ctx, "the UInt8Array passed to send is detached")
})?
.to_vec();

// Build the remote address if provided
let remote_address = match (addr, port) {
(Some(a), Some(p)) => {
Expand Down
7 changes: 0 additions & 7 deletions crates/wasm-rquickjs/skeleton/src/builtin/encoding.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
ERR_ENCODING_NOT_SUPPORTED,
ERR_INVALID_ARG_TYPE,
ERR_INVALID_THIS,
ERR_NO_ICU,
} from '__wasm_rquickjs_builtin/internal/errors';

const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom');
Expand Down Expand Up @@ -144,9 +143,6 @@ export class TextDecoder {
validateOptions(options);
const encoding = normalizeLabel(label);
const fatal = !!options?.fatal;
if (fatal) {
throw new ERR_NO_ICU('fatal');
}

textDecoderState.set(this, {
encoding,
Expand Down Expand Up @@ -248,9 +244,6 @@ export class TextDecoderStream extends streams.TransformStream {
validateOptions(options);
const encoding = normalizeLabel(label);
const fatal = !!options?.fatal;
if (fatal) {
throw new ERR_NO_ICU('fatal');
}

let decoder;
super({
Expand Down
211 changes: 146 additions & 65 deletions crates/wasm-rquickjs/skeleton/src/builtin/http.js

Large diffs are not rendered by default.

152 changes: 139 additions & 13 deletions crates/wasm-rquickjs/skeleton/src/builtin/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,91 @@ impl<'js> IntoJs<'js> for RedirectPolicy {
}
}

/// Runs `fut`, cancelling it if the JS `AbortSignal` fires, and rejecting with
/// the signal's reason.
///
/// The cancellation has to happen *inside* the future: rquickjs spawns the
/// future backing an async method detached from the promise it returns, so a
/// JS-side `Promise.race` only drops a promise reference while the request
/// keeps running. Aborting here drops `fut` instead, which releases the request
/// execution and with it the WASI `future-incoming-response` — dropping that
/// handle is what tells the host to cancel the in-flight request.
///
/// Mirrors the `pollable.abortablePromise(signal)` helper generated in
/// `crates/wasm-rquickjs/src/imports.rs`.
async fn with_abort_signal<'js, F, T>(
ctx: &Ctx<'js>,
signal: Option<Value<'js>>,
fut: F,
) -> rquickjs::Result<T>
where
F: std::future::Future<Output = rquickjs::Result<T>>,
{
use futures::future::{AbortHandle, Abortable};
use rquickjs::function::This;

let signal = match signal {
Some(signal) if !signal.is_undefined() && !signal.is_null() => signal,
_ => return fut.await,
};

let signal_obj = rquickjs::Object::from_value(signal)?;

// Already aborted: never start the request at all.
if signal_obj.get::<_, bool>("aborted")? {
let reason: Value<'js> = signal_obj.get("reason")?;
return Err(ctx.throw(reason));
}

let add_event_listener: rquickjs::Function<'js> = signal_obj.get("addEventListener")?;
let remove_event_listener: rquickjs::Function<'js> = signal_obj.get("removeEventListener")?;

let (abort_handle, abort_reg) = AbortHandle::new_pair();
let abort_fn = rquickjs::Function::new(ctx.clone(), move || {
abort_handle.abort();
})?;

let opts = rquickjs::Object::new(ctx.clone())?;
opts.set("once", true)?;
add_event_listener.call::<_, ()>((
This(signal_obj.clone()),
"abort",
abort_fn.clone(),
opts,
))?;

// Close the race: the signal may have aborted between the check above and
// the listener being registered.
if signal_obj.get::<_, bool>("aborted")? {
let _ = remove_event_listener.call::<_, ()>((
This(signal_obj.clone()),
"abort",
abort_fn,
));
let reason: Value<'js> = signal_obj.get("reason")?;
return Err(ctx.throw(reason));
}

// The JS handles are held directly across the await. They are reference
// counted and this is a single `'js`-scoped future, so nothing can collect
// them; round-tripping through `Persistent` would only add a fallible step
// between the await and the listener removal below.
let result = Abortable::new(fut, abort_reg).await;

// Always detach the listener: a long-lived signal (a reused controller, or an
// `AbortSignal.timeout` held elsewhere) would otherwise accumulate one per
// request.
let _ = remove_event_listener.call::<_, ()>((This(signal_obj.clone()), "abort", abort_fn));

match result {
Ok(inner) => inner,
Err(_aborted) => {
let reason: Value<'js> = signal_obj.get("reason")?;
Err(ctx.throw(reason))
}
}
}

#[derive(Trace, JsLifetime)]
#[rquickjs::class(rename_all = "camelCase")]
pub struct HttpRequest {
Expand Down Expand Up @@ -458,23 +543,47 @@ impl HttpRequest {
}
}

pub async fn receive_response<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result<HttpResponse> {
if let Some(execution) = self.execution.take() {
let response = execution
.receive_response()
.await
.map_err(|_| Exception::throw_message(&ctx, "Failed to receive HTTP response"))?;

Ok(HttpResponse::from_response(response))
} else {
Err(Exception::throw_message(
pub async fn receive_response<'js>(
&mut self,
ctx: Ctx<'js>,
signal: Option<Value<'js>>,
) -> rquickjs::Result<HttpResponse> {
let Some(execution) = self.execution.take() else {
return Err(Exception::throw_message(
&ctx,
"HTTP request has not been initialized for sending",
))
}
));
};

// `execution` is moved into the future, so an abort drops it here and
// releases the underlying WASI future-incoming-response.
let inner_ctx = ctx.clone();
let receive = async move {
let response = execution.receive_response().await.map_err(|_| {
Exception::throw_message(&inner_ctx, "Failed to receive HTTP response")
})?;
Ok(HttpResponse::from_response(response))
};

with_abort_signal(&ctx, signal, receive).await
}

pub async fn simple_send<'js>(&mut self, ctx: Ctx<'js>) -> rquickjs::Result<HttpResponse> {
pub async fn simple_send<'js>(
&mut self,
ctx: Ctx<'js>,
signal: Option<Value<'js>>,
) -> rquickjs::Result<HttpResponse> {
// The whole redirect loop runs inside the abortable future. The client,
// request and response future are locals of `simple_send_inner`, so an
// abort drops them all and cancels the in-flight request.
let send = self.simple_send_inner(ctx.clone());
with_abort_signal(&ctx, signal, send).await
}

async fn simple_send_inner<'js>(
&mut self,
ctx: Ctx<'js>,
) -> rquickjs::Result<HttpResponse> {
// Validate mode constraints
if self.mode == RequestMode::NoCors {
let method_str = self.method.to_string().to_uppercase();
Expand Down Expand Up @@ -667,6 +776,14 @@ impl WrappedRequestBodyWriter {
))
}
}

/// Drop the outgoing body *without* finishing it. Dropping the WASI writer is
/// what tells the host to cancel the still-incomplete request, so an aborted
/// upload is released now instead of lingering until JS GC eventually
/// collects this wrapper. Idempotent and infallible.
pub fn abort_body(&mut self) {
let _ = self.writer.take();
}
}

#[derive(Trace, JsLifetime)]
Expand Down Expand Up @@ -724,6 +841,15 @@ impl HttpResponse {
}
}

/// Drop the response body without reading it, releasing the native
/// incoming-body and the pollable behind it. Used on an intermediate redirect
/// response the fetch loop is about to abandon — otherwise that body lingers
/// until JS GC, and aborting mid-chain leaves the invocation busy on it.
#[qjs(rename = "discardBody")]
pub fn discard_body(&mut self) {
self.body_source = ResponseBodySource::Consumed;
}

#[qjs(rename = "makeOpaque")]
pub fn make_opaque(&mut self) {
self.is_opaque = true;
Expand Down
83 changes: 63 additions & 20 deletions crates/wasm-rquickjs/skeleton/src/builtin/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@ class WebSocket {
this._extensions = '';
this._protocol = '';
this._connection = null;
// Outbound frames must go on the wire in send() call order. Blob sends
// resolve asynchronously (via Blob.arrayBuffer()), so once one is pending
// every later send — even a synchronous string — has to queue behind it,
// or it would overtake the Blob. `_sendPending` marks that a drain is in
// flight; while it is, send() only enqueues.
this._sendQueue = [];
this._sendPending = false;

// Event handlers
this._onopen = null;
Expand Down Expand Up @@ -278,6 +285,24 @@ class WebSocket {
return;
}

// A Blob is drained asynchronously, so anything queued after it (or sent
// while an earlier Blob is still draining) must also wait, to preserve the
// wire order. Everything else is sent synchronously on the fast path.
const isBlob = typeof Blob !== 'undefined' && data instanceof Blob;
if (this._sendPending || isBlob) {
this._sendQueue.push(data);
if (!this._sendPending) {
this._drainSendQueue();
}
return;
}

this._sendNow(data);
}

// Synchronous send of a non-Blob frame. Blobs never reach here; they are
// resolved in _drainSendQueue.
_sendNow(data) {
try {
if (typeof data === 'string') {
this._bufferedAmount += utf8ByteLength(data);
Expand All @@ -291,25 +316,6 @@ class WebSocket {
this._bufferedAmount += data.byteLength;
this._connection.send_binary(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
this._bufferedAmount = 0;
} else if (typeof Blob !== 'undefined' && data instanceof Blob) {
// Blob support: read as ArrayBuffer and send as binary
const reader = new FileReader();
reader.onload = () => {
if (this._readyState === OPEN && this._connection) {
try {
const buf = new Uint8Array(reader.result);
this._bufferedAmount += buf.byteLength;
this._connection.send_binary(buf);
this._bufferedAmount = 0;
} catch (e2) {
this._bufferedAmount = 0;
this._readyState = CLOSED;
this._dispatch('error', new ErrorEvent(e2.message || String(e2)));
this._dispatch('close', new CloseEvent(1006, '', false));
}
}
};
reader.readAsArrayBuffer(data);
} else {
// Fallback: coerce to string per spec
const str = String(data);
Expand All @@ -325,6 +331,41 @@ class WebSocket {
}
}

// Drains queued frames strictly in order. A Blob is read via
// Blob.arrayBuffer() (the runtime has no FileReader) and sent as binary;
// frames enqueued while a Blob is resolving are picked up on the next turn,
// so ordering holds.
async _drainSendQueue() {
this._sendPending = true;
try {
while (this._sendQueue.length > 0) {
const data = this._sendQueue.shift();
if (typeof Blob !== 'undefined' && data instanceof Blob) {
let buf;
try {
buf = new Uint8Array(await data.arrayBuffer());
} catch (e2) {
this._bufferedAmount = 0;
this._readyState = CLOSED;
this._sendQueue.length = 0;
this._dispatch('error', new ErrorEvent(e2.message || String(e2)));
this._dispatch('close', new CloseEvent(1006, '', false));
return;
}
if (this._readyState === OPEN && this._connection) {
this._bufferedAmount += buf.byteLength;
this._connection.send_binary(buf);
this._bufferedAmount = 0;
}
} else if (this._readyState === OPEN && this._connection) {
this._sendNow(data);
}
}
} finally {
this._sendPending = false;
}
}

close(code, reason) {
if (this._readyState === CLOSING || this._readyState === CLOSED) {
return;
Expand Down Expand Up @@ -481,7 +522,7 @@ class WebSocketStream {
});

const writable = new WritableStream({
write(chunk) {
async write(chunk) {
if (!conn) {
throw new Error('WebSocketStream is closed');
}
Expand All @@ -491,6 +532,8 @@ class WebSocketStream {
conn.send_binary(new Uint8Array(chunk));
} else if (ArrayBuffer.isView(chunk)) {
conn.send_binary(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
} else if (typeof Blob !== 'undefined' && chunk instanceof Blob) {
conn.send_binary(new Uint8Array(await chunk.arrayBuffer()));
} else {
conn.send_text(String(chunk));
}
Expand Down
20 changes: 18 additions & 2 deletions crates/wasm-rquickjs/skeleton/src/builtin/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,28 @@ impl WsConnection {
.map_err(|e| Exception::throw_message(&ctx, &format!("WebSocket send failed: {e:?}")))
}

pub fn send_binary(&self, ctx: Ctx<'_>, data: Vec<u8>) -> rquickjs::Result<()> {
pub fn send_binary(&self, ctx: Ctx<'_>, data: rquickjs::TypedArray<'_, u8>) -> rquickjs::Result<()> {
// Accept a typed array (the JS side passes a Uint8Array). rquickjs cannot
// convert a Uint8Array to `Vec<u8>` — that expects a plain JS Array — so a
// `Vec<u8>` parameter rejects every binary send with
// "Error converting from js 'object' into type 'array'". Copy the bytes out
// of the typed array instead, mirroring `HttpRequest::uint8_array_body`.
// The `to_vec` is the single minimal copy needed to build the owned
// `list<u8>` message the host expects (which copies the bytes across the
// guest boundary regardless); it is a bulk memcpy, not a per-element clone.
// A detached buffer has no bytes to send, so report it rather than
// silently transmitting an empty frame.
let bytes = data
.as_bytes()
.ok_or_else(|| {
Exception::throw_message(&ctx, "the UInt8Array passed to send is detached")
})?
.to_vec();
let inner = self.inner.borrow();
let conn = inner
.as_ref()
.ok_or_else(|| Exception::throw_message(&ctx, "WebSocket is closed"))?;
conn.send(&Message::Binary(data))
conn.send(&Message::Binary(bytes))
.map_err(|e| Exception::throw_message(&ctx, &format!("WebSocket send failed: {e:?}")))
}

Expand Down
Loading