Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/js-host-api/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
41 changes: 41 additions & 0 deletions src/js-host-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────
Expand Down
103 changes: 103 additions & 0 deletions src/js-host-api/tests/sandbox.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading