Skip to main content

nodejs/stdlib/
stream.rs

1//! Node `stream` module: native base classes + module helper functions.
2//!
3//! The base classes (`Readable`/`Writable`/`Duplex`/`Transform`/`PassThrough`/
4//! `Stream`) are EventEmitter-backed objects (same `@@native`/`@@on`/`@@once`
5//! shape as `net` sockets) exposing the surface higher layers touch today:
6//! `on`/`once`/`emit`, `write`/`end`, `push`/`read`, and a best-effort `pipe`.
7//! `http`'s `req`/`res` are their own native objects (see `http.rs`); this module
8//! exists so `require('stream')` yields the base constructors and the module
9//! helper functions (`finished`, `pipeline`, `isReadable`, …).
10//!
11//! Lifecycle state is tracked with hidden boolean props set as terminal events
12//! fire: `@@ended` (readable end), `@@finished` (writable finish), `@@destroyed`
13//! (close/destroy), `@@errored` (error value), `@@disturbed` (read/resume/pipe).
14//! `finished(stream, cb)` callbacks live in a `@@finished` array drained on the
15//! first terminal event so the callback fires exactly once.
16
17use crate::host::{with_host, JsObj};
18use fusevm::Value;
19use indexmap::IndexMap;
20use std::cell::Cell;
21
22// Module-state default high-water marks (byte mode / object mode). Node v26
23// defaults: 65536 bytes, 16 objects; `setDefaultHighWaterMark` mutates these.
24thread_local! {
25    static DEFAULT_HWM_BYTES: Cell<f64> = const { Cell::new(65536.0) };
26    static DEFAULT_HWM_OBJ: Cell<f64> = const { Cell::new(16.0) };
27}
28
29/// The base classes exported by `require('stream')`.
30pub const CLASSES: &[&str] = &[
31    "Readable",
32    "Writable",
33    "Duplex",
34    "Transform",
35    "PassThrough",
36    "Stream",
37];
38
39/// The module free-functions exported by `require('stream')`.
40pub const METHODS: &[&str] = &[
41    "finished",
42    "pipeline",
43    "addAbortSignal",
44    "destroy",
45    "isReadable",
46    "isWritable",
47    "isErrored",
48    "isDestroyed",
49    "isDisturbed",
50    "getDefaultHighWaterMark",
51    "setDefaultHighWaterMark",
52];
53
54/// True if `name` is one of the stream base-class constructors.
55pub fn is_class(name: &str) -> bool {
56    CLASSES.contains(&name)
57}
58
59/// `stream.<Class>` property (a constructor value), reachable via
60/// `namespace_property` → `stdlib::constant`.
61pub fn constant(name: &str) -> Option<Value> {
62    if is_class(name) {
63        return Some(with_host(|h| h.alloc(JsObj::Builtin(name.to_string()))));
64    }
65
66    // `stream.promises` is the `stream/promises` module.
67    if name == "promises" {
68        return Some(with_host(|h| {
69            h.alloc(JsObj::Builtin("stream/promises".to_string()))
70        }));
71    }
72    None
73}
74
75/// `new Readable()` / `Writable` / `Duplex` / `Transform` / `PassThrough` /
76/// `Stream`.
77pub fn construct(name: &str, args: &[Value]) -> Value {
78    // A `push`ed-data queue lives on the object as an array for `read`.
79    let mut extra = IndexMap::new();
80    let queue = with_host(|h| h.new_array(Vec::new()));
81    extra.insert("@@queue".into(), queue);
82    // `new Writable({ write(chunk, enc, cb) {…} })` supplies the implementation
83    // the stream is supposed to run — that option is the whole point of
84    // constructing one directly, and it was DISCARDED: `construct` took only the
85    // class name, so a custom sink silently swallowed every chunk. Keep the
86    // callbacks the write path uses.
87    if let Some(opts) = args.first() {
88        // `new Transform({ transform(chunk, enc, cb) {…} })` supplies the
89        // conversion the stream exists to perform. It was discarded like the
90        // `write` option once was, so a Transform passed every chunk through
91        // UNCHANGED and still looked like it worked.
92        for (opt, key) in [
93            ("write", "@@writeImpl"),
94            ("final", "@@finalImpl"),
95            ("transform", "@@transformImpl"),
96            ("flush", "@@flushImpl"),
97        ] {
98            if let Some(f) = opt_callable(opts, opt) {
99                extra.insert(key.into(), f);
100            }
101        }
102    }
103    // `readable`/`writable`/`destroyed` are live PROPERTIES on a node stream and
104    // read back as `undefined` here — the internal `@@ended`/`@@finished`/
105    // `@@destroyed` flags existed but nothing exposed them. `emit_event` keeps
106    // them in step as the lifecycle events fire.
107    // Only the side that APPLIES gets a property: node leaves `writable`
108    // undefined on a plain Readable rather than reporting false, and a library
109    // distinguishing a duplex from a one-way stream tests exactly that.
110    if matches!(name, "Readable" | "Duplex" | "Transform" | "PassThrough") {
111        extra.insert("readable".into(), Value::Bool(true));
112    }
113    if matches!(name, "Writable" | "Duplex" | "Transform" | "PassThrough") {
114        extra.insert("writable".into(), Value::Bool(true));
115    }
116    extra.insert("destroyed".into(), Value::Bool(false));
117    super::net::new_emitter_object(name, extra)
118}
119
120/// An own property of `v` that is callable, else `None`.
121fn opt_callable(v: &Value, key: &str) -> Option<Value> {
122    let f = with_host(|h| match h.get(v) {
123        Some(JsObj::Object(m)) => m.get(key).cloned(),
124        _ => None,
125    })?;
126    with_host(|h| crate::host::is_callable(h, &f)).then_some(f)
127}
128
129/// Run the `write(chunk, encoding, callback)` implementation the constructor was
130/// given, if any. The callback is required by the contract, so a no-op function
131/// is supplied when the implementation asks for one.
132fn run_write_impl(recv: &Value, chunk: &Value) -> Result<(), String> {
133    let Some(f) = hidden_prop(recv, "@@writeImpl") else {
134        return Ok(());
135    };
136    let enc = with_host(|h| h.new_str("utf8".to_string()));
137    // `_write` is handed a `callback` it is contractually required to call.
138    // Nothing here waits on backpressure, so it only has to BE callable —
139    // a `write(c, e, cb) { …; cb(); }` implementation throws without it.
140    let cb = with_host(|h| h.alloc(JsObj::Builtin("@@streamWriteCallback".into())));
141    crate::host::invoke(&f, vec![chunk.clone(), enc, cb], None)?;
142    Ok(())
143}
144
145fn hidden_prop(recv: &Value, key: &str) -> Option<Value> {
146    with_host(|h| match h.get(recv) {
147        Some(JsObj::Object(m)) => m.get(key).cloned(),
148        _ => None,
149    })
150}
151
152/// Take one written chunk: hand it to whichever implementation the constructor
153/// was given, and emit what comes out.
154///
155/// A `transform` implementation decides the output itself — it calls back with
156/// the converted chunk — so the raw one must NOT also be emitted. A `write`
157/// implementation is a sink and emits the chunk unchanged.
158fn accept_chunk(recv: &Value, chunk: &Value) -> Result<(), String> {
159    if let Some(f) = hidden_prop(recv, "@@transformImpl") {
160        let enc = with_host(|h| h.new_str("utf8".to_string()));
161        let cb = match recv {
162            Value::Obj(i) => with_host(|h| h.alloc(JsObj::Builtin(format!("@@transformCb:{i}")))),
163            _ => Value::Undef,
164        };
165        crate::host::invoke(&f, vec![chunk.clone(), enc, cb], None)?;
166        return Ok(());
167    }
168    run_write_impl(recv, chunk)?;
169    emit_event(recv, "data", vec![chunk.clone()])?;
170    Ok(())
171}
172
173/// The callback a `transform` implementation invokes: `cb(err, chunk)`. A
174/// nullish chunk contributes nothing, matching `push(null)`-style suppression.
175pub fn transform_callback(recv: &Value, args: &[Value]) -> Result<(), String> {
176    if let Some(err) = args.first().filter(|e| !with_host(|h| h.is_nullish(e))) {
177        emit_event(recv, "error", vec![err.clone()])?;
178        return Ok(());
179    }
180    if let Some(out) = args.get(1).filter(|c| !with_host(|h| h.is_nullish(c))) {
181        emit_event(recv, "data", vec![out.clone()])?;
182    }
183    Ok(())
184}
185
186/// Statics on the stream CONSTRUCTORS (`Readable.from`, not `stream.from`).
187pub const STATIC_METHODS: &[&str] = &["from", "isDisturbed"];
188
189/// `Readable.from(iterable)` / `Duplex.from(iterable)`.
190///
191/// The whole point of it is that a caller attaches its `data` listener AFTER
192/// the call returns, so the items cannot be emitted while building the stream —
193/// this model emits `data` synchronously from `push`, and pushing here would
194/// fire every chunk into a stream nobody is listening to yet. The items are
195/// queued and drained from a microtask instead, which is the same "next tick"
196/// ordering node gives.
197pub fn static_call(cls: &str, method: &str, args: &[Value]) -> Option<Result<Value, String>> {
198    Some(match method {
199        "from" => Ok(from_iterable(cls, args)),
200        "isDisturbed" => Ok(Value::Bool(
201            hidden_prop(
202                &args.first().cloned().unwrap_or(Value::Undef),
203                "@@disturbed",
204            )
205            .is_some_and(|v| with_host(|h| h.truthy(&v))),
206        )),
207        _ => return None,
208    })
209}
210
211fn from_iterable(cls: &str, args: &[Value]) -> Value {
212    let src = args.first().cloned().unwrap_or(Value::Undef);
213    // A string (or Buffer) is ONE chunk, not one per character — node yields
214    // `'ab'` whole from `Readable.from('ab')`, and iterating it would turn every
215    // string source into a stream of single characters.
216    let whole = with_host(|h| h.as_str(&src).is_some())
217        || super::native_tag(&src).as_deref() == Some("Buffer");
218    let items = if whole {
219        vec![src.clone()]
220    } else {
221        crate::host::iter_all(&src).unwrap_or_default()
222    };
223    let stream = construct(
224        if cls == "Duplex" {
225            "Duplex"
226        } else {
227            "Readable"
228        },
229        &[],
230    );
231    if let Some(q) = queue_of(&stream) {
232        with_host(|h| {
233            if let Some(JsObj::Array(dst)) = h.get_mut(&q) {
234                dst.extend(items);
235            }
236        });
237    }
238    if let Value::Obj(i) = stream {
239        let thunk = with_host(|h| h.alloc(JsObj::Builtin(format!("@@streamFlush:{i}"))));
240        with_host(|h| h.queue_micro(thunk, Vec::new()));
241    }
242    stream
243}
244
245/// Drain a `Readable.from` queue: one `data` per item, then `end`.
246pub fn flush_from(recv: &Value) -> Result<(), String> {
247    let items = match queue_of(recv) {
248        Some(q) => with_host(|h| match h.get_mut(&q) {
249            Some(JsObj::Array(v)) => std::mem::take(v),
250            _ => Vec::new(),
251        }),
252        None => Vec::new(),
253    };
254    for item in items {
255        emit_event(recv, "data", vec![item])?;
256    }
257    emit_event(recv, "end", Vec::new())?;
258    Ok(())
259}
260
261/// Module free-function dispatch (`stream.finished`, `stream.isReadable`, …).
262pub fn call(method: &str, args: &[Value]) -> Option<Result<Value, String>> {
263    let s0 = || args.first().cloned().unwrap_or(Value::Undef);
264    Some(match method {
265        "getDefaultHighWaterMark" => Ok(get_default_hwm(args)),
266        "setDefaultHighWaterMark" => Ok(set_default_hwm(args)),
267        "isReadable" => Ok(Value::Bool(is_readable(&s0()))),
268        "isWritable" => Ok(Value::Bool(is_writable(&s0()))),
269        "isErrored" => Ok(Value::Bool(flag(&s0(), "@@errored"))),
270        "isDestroyed" => Ok(Value::Bool(flag(&s0(), "@@destroyed"))),
271        "isDisturbed" => Ok(Value::Bool(flag(&s0(), "@@disturbed"))),
272        "destroy" => Ok(destroy_stream(args)),
273        "finished" => Ok(finished(args)),
274        "pipeline" => pipeline(args),
275        "addAbortSignal" => Ok(add_abort_signal(args)),
276        _ => return None,
277    })
278}
279
280fn get_default_hwm(args: &[Value]) -> Value {
281    let obj = args
282        .first()
283        .map(|v| with_host(|h| h.truthy(v)))
284        .unwrap_or(false);
285    let n = if obj {
286        DEFAULT_HWM_OBJ.with(|c| c.get())
287    } else {
288        DEFAULT_HWM_BYTES.with(|c| c.get())
289    };
290    Value::Float(n)
291}
292
293fn set_default_hwm(args: &[Value]) -> Value {
294    let obj = args
295        .first()
296        .map(|v| with_host(|h| h.truthy(v)))
297        .unwrap_or(false);
298    let val = super::arg_num(args, 1);
299    if obj {
300        DEFAULT_HWM_OBJ.with(|c| c.set(val));
301    } else {
302        DEFAULT_HWM_BYTES.with(|c| c.set(val));
303    }
304    Value::Undef
305}
306
307// ── lifecycle-flag helpers ──────────────────────────────────────────────────
308
309fn tag_of(recv: &Value) -> Option<String> {
310    with_host(|h| match h.get(recv) {
311        Some(JsObj::Object(p)) => p.get("@@native").map(|v| h.str_of(v)),
312        _ => None,
313    })
314}
315
316fn flag(recv: &Value, key: &str) -> bool {
317    with_host(|h| match h.get(recv) {
318        Some(JsObj::Object(p)) => p.get(key).map(|v| h.truthy(v)).unwrap_or(false),
319        _ => false,
320    })
321}
322
323/// Mark one side of a stream closed — but only if that side existed. A plain
324/// Readable has no `writable` property at all and must not gain one.
325fn clear_side(recv: &Value, key: &str) {
326    let present =
327        with_host(|h| matches!(h.get(recv), Some(JsObj::Object(p)) if p.contains_key(key)));
328    if present {
329        set_flag(recv, key, Value::Bool(false));
330    }
331}
332
333fn set_flag(recv: &Value, key: &str, v: Value) {
334    with_host(|h| {
335        if let Some(JsObj::Object(p)) = h.get_mut(recv) {
336            p.insert(key.to_string(), v);
337        }
338    });
339}
340
341fn is_readable(s: &Value) -> bool {
342    let Some(t) = tag_of(s) else { return false };
343    matches!(
344        t.as_str(),
345        "Readable" | "Duplex" | "Transform" | "PassThrough"
346    ) && !flag(s, "@@destroyed")
347        && !flag(s, "@@ended")
348}
349
350fn is_writable(s: &Value) -> bool {
351    let Some(t) = tag_of(s) else { return false };
352    matches!(
353        t.as_str(),
354        "Writable" | "Duplex" | "Transform" | "PassThrough"
355    ) && !flag(s, "@@destroyed")
356        && !flag(s, "@@finished")
357}
358
359// ── `finished` callback registry ────────────────────────────────────────────
360
361fn add_finished(recv: &Value, cb: Value) {
362    with_host(|h| {
363        let existing = match h.get(recv) {
364            Some(JsObj::Object(p)) => p.get("@@finished").cloned(),
365            _ => None,
366        };
367        let arr = match existing {
368            Some(a) if matches!(h.get(&a), Some(JsObj::Array(_))) => a,
369            _ => {
370                let a = h.new_array(Vec::new());
371                if let Some(JsObj::Object(p)) = h.get_mut(recv) {
372                    p.insert("@@finished".into(), a.clone());
373                }
374                a
375            }
376        };
377        if let Some(JsObj::Array(items)) = h.get_mut(&arr) {
378            items.push(cb);
379        }
380    });
381}
382
383fn take_finished(recv: &Value) -> Vec<Value> {
384    with_host(|h| {
385        let arr = match h.get_mut(recv) {
386            Some(JsObj::Object(p)) => p.shift_remove("@@finished"),
387            _ => None,
388        };
389        match arr {
390            Some(av) => match h.get(&av) {
391                Some(JsObj::Array(items)) => items.clone(),
392                _ => Vec::new(),
393            },
394            None => Vec::new(),
395        }
396    })
397}
398
399/// Emit `name` (with `extra` args), set the matching lifecycle flag, and drain
400/// `finished` callbacks on the first terminal event so each fires once.
401fn emit_event(recv: &Value, name: &str, extra: Vec<Value>) -> Result<Value, String> {
402    let mut a = vec![with_host(|h| h.new_str(name))];
403    a.extend(extra.iter().cloned());
404    // The state change lands BEFORE the listeners run: by the time `end` fires,
405    // node already reports `readable === false`, and a handler that checks is
406    // asking about the stream it is being told about. Setting the flags after
407    // the emit showed handlers the previous state.
408    match name {
409        "end" => {
410            set_flag(recv, "@@ended", Value::Bool(true));
411            clear_side(recv, "readable");
412        }
413        "finish" => {
414            set_flag(recv, "@@finished", Value::Bool(true));
415            clear_side(recv, "writable");
416        }
417        "close" => {
418            set_flag(recv, "@@destroyed", Value::Bool(true));
419            set_flag(recv, "destroyed", Value::Bool(true));
420            clear_side(recv, "readable");
421            clear_side(recv, "writable");
422        }
423        "error" => set_flag(
424            recv,
425            "@@errored",
426            extra.first().cloned().unwrap_or(Value::Bool(true)),
427        ),
428        _ => {}
429    }
430    let r = super::events::instance_call(recv, "emit", a)?;
431    if matches!(name, "end" | "finish" | "close" | "error") {
432        let cbs = take_finished(recv);
433        let arg = if name == "error" {
434            extra.first().cloned().unwrap_or(Value::Undef)
435        } else {
436            Value::Undef
437        };
438        for cb in cbs {
439            crate::host::invoke(&cb, vec![arg.clone()], None)?;
440        }
441    }
442    Ok(r)
443}
444
445// ── module free functions ───────────────────────────────────────────────────
446
447/// `stream.finished(stream[, options], callback)` — invoke `callback(err)` once
448/// when the stream ends/finishes/closes/errors. Fires immediately if the stream
449/// has already reached a terminal state. Returns `undefined` (Node returns a
450/// cleanup fn; not tracked — best-effort).
451fn finished(args: &[Value]) -> Value {
452    let stream = args.first().cloned().unwrap_or(Value::Undef);
453    let cb = args
454        .iter()
455        .rev()
456        .find(|v| with_host(|h| crate::host::is_callable(h, v)))
457        .cloned()
458        .unwrap_or(Value::Undef);
459    if flag(&stream, "@@ended") || flag(&stream, "@@finished") || flag(&stream, "@@destroyed") {
460        let _ = crate::host::invoke(&cb, vec![Value::Undef], None);
461    } else {
462        add_finished(&stream, cb);
463    }
464    Value::Undef
465}
466
467/// `stream.pipeline(source, ...transforms, dest[, callback])` — chain via
468/// `.pipe()` and register `callback` on the destination's completion. Returns
469/// the destination stream.
470fn pipeline(args: &[Value]) -> Result<Value, String> {
471    if args.is_empty() {
472        // Node validates the LAST argument (the callback slot) first, so an
473        // empty call reports that property, not a bespoke arity sentence.
474        return Err(crate::host::invalid_arg_type(
475            "streams[stream.length - 1]",
476            "property",
477            "function",
478            &Value::Undef,
479        ));
480    }
481    let cb_idx = args
482        .iter()
483        .rposition(|v| with_host(|h| crate::host::is_callable(h, v)));
484    let (streams, cb) = match cb_idx {
485        Some(i) if i == args.len() - 1 => (&args[..i], Some(args[i].clone())),
486        _ => (args, None),
487    };
488    for w in streams.windows(2) {
489        crate::host::call_method(&w[0], "pipe", vec![w[1].clone()])?;
490    }
491    let last = streams.last().cloned().unwrap_or(Value::Undef);
492    if let Some(cb) = cb {
493        add_finished(&last, cb);
494    }
495    Ok(last)
496}
497
498/// `stream.destroy(stream[, err])` — emit `error` (if `err` given) then `close`
499/// and mark the stream destroyed.
500fn destroy_stream(args: &[Value]) -> Value {
501    let stream = args.first().cloned().unwrap_or(Value::Undef);
502    if flag(&stream, "@@destroyed") {
503        return stream;
504    }
505    if let Some(e) = args.get(1).cloned() {
506        if !with_host(|h| h.is_nullish(&e)) {
507            let _ = emit_event(&stream, "error", vec![e]);
508        }
509    }
510    let _ = emit_event(&stream, "close", vec![]);
511    set_flag(&stream, "@@destroyed", Value::Bool(true));
512    stream
513}
514
515/// `stream.addAbortSignal(signal, stream)` — best-effort: `AbortSignal` is not
516/// modeled in this runtime, so this returns `stream` unchanged.
517fn add_abort_signal(args: &[Value]) -> Value {
518    args.get(1).cloned().unwrap_or(Value::Undef)
519}
520
521/// Instance dispatch for a stream base class. EventEmitter methods are delegated
522/// to `events`; `emit` routes through `emit_event` for lifecycle tracking.
523pub fn instance_call(
524    tag: &str,
525    recv: &Value,
526    method: &str,
527    args: Vec<Value>,
528) -> Result<Value, String> {
529    let _ = tag;
530    if method == "emit" {
531        let name = args
532            .first()
533            .map(|v| with_host(|h| h.str_of(v)))
534            .unwrap_or_default();
535        let extra = args.get(1..).map(|s| s.to_vec()).unwrap_or_default();
536        return emit_event(recv, &name, extra);
537    }
538    // `emit` is intercepted above (lifecycle tracking); every other name in
539    // `events::METHODS` delegates. Reading the set from `events` rather than
540    // re-listing it is what puts `listeners`/`setMaxListeners`/`getMaxListeners`
541    // on a stream — the local copy was missing all three.
542    if super::events::METHODS.contains(&method) {
543        return super::events::instance_call(recv, method, args);
544    }
545    match method {
546        "write" => {
547            let chunk = args.first().cloned().unwrap_or(Value::Undef);
548            accept_chunk(recv, &chunk)?;
549            Ok(Value::Bool(true))
550        }
551        "end" => {
552            if let Some(chunk) = args.first().filter(|v| !matches!(v, Value::Undef)) {
553                accept_chunk(recv, chunk)?;
554            }
555            emit_event(recv, "finish", vec![])?;
556            emit_event(recv, "end", vec![])?;
557            Ok(recv.clone())
558        }
559        "push" => {
560            let chunk = args.first().cloned().unwrap_or(Value::Undef);
561            if with_host(|h| h.is_nullish(&chunk)) {
562                emit_event(recv, "end", vec![])?;
563                return Ok(Value::Bool(false));
564            }
565            if let Some(q) = queue_of(recv) {
566                with_host(|h| {
567                    if let Some(JsObj::Array(items)) = h.get_mut(&q) {
568                        items.push(chunk.clone());
569                    }
570                });
571            }
572            emit_event(recv, "data", vec![chunk])?;
573            Ok(Value::Bool(true))
574        }
575        "read" => {
576            set_flag(recv, "@@disturbed", Value::Bool(true));
577            if let Some(q) = queue_of(recv) {
578                let next = with_host(|h| match h.get_mut(&q) {
579                    Some(JsObj::Array(items)) if !items.is_empty() => Some(items.remove(0)),
580                    _ => None,
581                });
582                if let Some(v) = next {
583                    return Ok(v);
584                }
585            }
586            Ok(with_host(|h| h.null()))
587        }
588        "pipe" => {
589            set_flag(recv, "@@disturbed", Value::Bool(true));
590            let dest = args.first().cloned().unwrap_or(Value::Undef);
591            if let Some(q) = queue_of(recv) {
592                let items = with_host(|h| match h.get(&q) {
593                    Some(JsObj::Array(items)) => items.clone(),
594                    _ => Vec::new(),
595                });
596                for chunk in items {
597                    crate::host::call_method(&dest, "write", vec![chunk])?;
598                }
599            }
600            Ok(dest)
601        }
602        "destroy" => {
603            if !flag(recv, "@@destroyed") {
604                if let Some(e) = args.first().filter(|v| !matches!(v, Value::Undef)) {
605                    let _ = emit_event(recv, "error", vec![e.clone()]);
606                }
607                let _ = emit_event(recv, "close", vec![]);
608                set_flag(recv, "@@destroyed", Value::Bool(true));
609            }
610            Ok(recv.clone())
611        }
612        "resume" => {
613            set_flag(recv, "@@disturbed", Value::Bool(true));
614            Ok(recv.clone())
615        }
616        "setEncoding" | "pause" | "cork" | "uncork" => Ok(recv.clone()),
617        _ => Err(crate::host::type_error(&format!(
618            "stream.{method} is not a function"
619        ))),
620    }
621}
622
623fn queue_of(recv: &Value) -> Option<Value> {
624    with_host(|h| match h.get(recv) {
625        Some(JsObj::Object(p)) => p.get("@@queue").cloned(),
626        _ => None,
627    })
628}