From 0e553bc0a9cbee193d99d50d6472bfd5b1a05f91 Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Wed, 18 Mar 2026 10:37:42 +0000 Subject: [PATCH] feat: expose host print function to Node.js API Add setHostPrintFn() to SandboxBuilder allowing Node.js callers to receive guest console.log/print output via a callback. - New setHostPrintFn(callback) method on SandboxBuilder (NAPI layer) - Uses ThreadsafeFunction in blocking mode for synchronous print semantics - Supports method chaining (returns this) - Added to lib.js sync wrapper list for error code extraction - 4 new vitest tests: chaining, single log, multiple logs, consumed error - index.d.ts auto-generated by napi build with correct TypeScript types Signed-off-by: Simon Davies --- src/js-host-api/lib.js | 16 ++++ src/js-host-api/src/lib.rs | 41 ++++++++++ src/js-host-api/tests/sandbox.test.js | 103 ++++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/src/js-host-api/lib.js b/src/js-host-api/lib.js index 6a82443..17fea72 100644 --- a/src/js-host-api/lib.js +++ b/src/js-host-api/lib.js @@ -207,6 +207,22 @@ for (const method of [ SandboxBuilder.prototype[method] = wrapSync(orig); } +// setHostPrintFn needs a custom wrapper: the user's callback is wrapped in +// try/catch before it reaches the native layer, because exceptions thrown +// inside a Blocking ThreadsafeFunction escape as unhandled errors. +{ + const origSetHostPrintFn = SandboxBuilder.prototype.setHostPrintFn; + SandboxBuilder.prototype.setHostPrintFn = wrapSync(function (callback) { + return origSetHostPrintFn.call(this, (msg) => { + try { + callback(msg); + } catch (e) { + console.error('Host print callback threw:', e); + } + }); + }); +} + // ── Re-export ──────────────────────────────────────────────────────── module.exports = native; diff --git a/src/js-host-api/src/lib.rs b/src/js-host-api/src/lib.rs index 8c4caa9..da59d9f 100644 --- a/src/js-host-api/src/lib.rs +++ b/src/js-host-api/src/lib.rs @@ -386,6 +386,47 @@ impl SandboxBuilderWrapper { inner: Arc::new(Mutex::new(Some(proto_sandbox))), }) } + + /// Set a callback that receives guest `console.log` / `print` output. + /// + /// Without this, guest print output is silently discarded. The callback + /// receives each print message as a string. + /// + /// If the callback throws, the exception is caught by the JS wrapper + /// (`lib.js`) and logged to `console.error`. Guest execution continues. + /// + /// @param callback - `(message: string) => void` — called for each print + /// @returns this (for chaining) + /// @throws If the builder has already been consumed by `build()` + #[napi] + pub fn set_host_print_fn( + &self, + #[napi(ts_arg_type = "(message: string) => void")] callback: ThreadsafeFunction< + String, // Rust → JS argument type + (), // JS return type (void) + String, // JS → Rust argument type (same — identity mapping) + Status, // Error status type + false, // Not CallerHandled (napi manages errors) + false, // Not accepting unknown return types + >, + ) -> napi::Result<&Self> { + self.with_inner(|b| { + // Blocking mode is intentional: the guest's print/console.log call + // is synchronous — the guest must wait for the print to complete + // before continuing execution. Unlike host functions (which use + // NonBlocking + oneshot channel for async Promise resolution), + // print is fire-and-forget with no return value to await. + let print_fn = move |msg: String| -> i32 { + let status = callback.call(msg, ThreadsafeFunctionCallMode::Blocking); + if status == Status::Ok { + 0 + } else { + -1 + } + }; + b.with_host_print_fn(print_fn.into()) + }) + } } // ── ProtoJSSandbox ─────────────────────────────────────────────────── diff --git a/src/js-host-api/tests/sandbox.test.js b/src/js-host-api/tests/sandbox.test.js index 1505224..e292841 100644 --- a/src/js-host-api/tests/sandbox.test.js +++ b/src/js-host-api/tests/sandbox.test.js @@ -308,3 +308,106 @@ describe('Calculator example', () => { expect(result.result).toBe(4); }); }); + +// ── Host print function ────────────────────────────────────────────── + +describe('setHostPrintFn', () => { + it('should support method chaining', () => { + const builder = new SandboxBuilder(); + const returned = builder.setHostPrintFn(() => {}); + expect(returned).toBe(builder); + }); + + it('should receive console.log output from the guest', async () => { + const messages = []; + const builder = new SandboxBuilder().setHostPrintFn((msg) => { + messages.push(msg); + }); + const proto = await builder.build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'handler', + `function handler(event) { + console.log("Hello from guest!"); + return event; + }` + ); + const loaded = await sandbox.getLoadedSandbox(); + await loaded.callHandler('handler', {}); + + expect(messages.join('')).toContain('Hello from guest!'); + }); + + it('should receive multiple console.log calls', async () => { + const messages = []; + const builder = new SandboxBuilder().setHostPrintFn((msg) => { + messages.push(msg); + }); + const proto = await builder.build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'handler', + `function handler(event) { + console.log("first"); + console.log("second"); + console.log("third"); + return event; + }` + ); + const loaded = await sandbox.getLoadedSandbox(); + await loaded.callHandler('handler', {}); + + const combined = messages.join(''); + expect(combined).toContain('first'); + expect(combined).toContain('second'); + expect(combined).toContain('third'); + }); + + it('should use the last callback when set multiple times', async () => { + const firstMessages = []; + const secondMessages = []; + const builder = new SandboxBuilder() + .setHostPrintFn((msg) => firstMessages.push(msg)) + .setHostPrintFn((msg) => secondMessages.push(msg)); + const proto = await builder.build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'handler', + `function handler(event) { + console.log("which callback?"); + return event; + }` + ); + const loaded = await sandbox.getLoadedSandbox(); + await loaded.callHandler('handler', {}); + + expect(firstMessages.length).toBe(0); + expect(secondMessages.join('')).toContain('which callback?'); + }); + + it('should continue guest execution when callback throws', async () => { + const builder = new SandboxBuilder().setHostPrintFn(() => { + throw new Error('print callback exploded'); + }); + const proto = await builder.build(); + const sandbox = await proto.loadRuntime(); + sandbox.addHandler( + 'handler', + `function handler(event) { + console.log("this will throw in the callback"); + return { survived: true }; + }` + ); + const loaded = await sandbox.getLoadedSandbox(); + const result = await loaded.callHandler('handler', {}); + + // The JS wrapper catches the throw — guest continues normally + expect(result.survived).toBe(true); + }); + + it('should throw CONSUMED after build()', async () => { + const builder = new SandboxBuilder(); + await builder.build(); + expectThrowsWithCode(() => builder.setHostPrintFn(() => {}), 'ERR_CONSUMED'); + }); +});