Skip to content

Commit a0d8f51

Browse files
committed
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<String> 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 <simongdavies@users.noreply.github.com>
1 parent ee21e83 commit a0d8f51

File tree

3 files changed

+168
-0
lines changed

3 files changed

+168
-0
lines changed

src/js-host-api/lib.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,30 @@ for (const method of [
207207
SandboxBuilder.prototype[method] = wrapSync(orig);
208208
}
209209

210+
// setHostPrintFn needs a custom wrapper: the user's callback is wrapped in
211+
// try/catch before it reaches the native layer, because exceptions thrown
212+
// inside a Blocking ThreadsafeFunction escape as unhandled errors.
213+
{
214+
const origSetHostPrintFn = SandboxBuilder.prototype.setHostPrintFn;
215+
if (!origSetHostPrintFn) {
216+
throw new Error('Cannot wrap missing method: SandboxBuilder.setHostPrintFn');
217+
}
218+
SandboxBuilder.prototype.setHostPrintFn = wrapSync(function (callback) {
219+
if (typeof callback !== 'function') {
220+
// Forward non-function values to the native method for consistent
221+
// validation errors (the Rust layer rejects non-callable arguments).
222+
return origSetHostPrintFn.call(this, callback);
223+
}
224+
return origSetHostPrintFn.call(this, (msg) => {
225+
try {
226+
callback(msg);
227+
} catch (e) {
228+
console.error('Host print callback threw:', e);
229+
}
230+
});
231+
});
232+
}
233+
210234
// ── Re-export ────────────────────────────────────────────────────────
211235

212236
module.exports = native;

src/js-host-api/src/lib.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,47 @@ impl SandboxBuilderWrapper {
386386
inner: Arc::new(Mutex::new(Some(proto_sandbox))),
387387
})
388388
}
389+
390+
/// Set a callback that receives guest `console.log` / `print` output.
391+
///
392+
/// Without this, guest print output is silently discarded. The callback
393+
/// receives each print message as a string.
394+
///
395+
/// If the callback throws, the exception is caught by the JS wrapper
396+
/// (`lib.js`) and logged to `console.error`. Guest execution continues.
397+
///
398+
/// @param callback - `(message: string) => void` — called for each print
399+
/// @returns this (for chaining)
400+
/// @throws If the builder has already been consumed by `build()`
401+
#[napi]
402+
pub fn set_host_print_fn(
403+
&self,
404+
#[napi(ts_arg_type = "(message: string) => void")] callback: ThreadsafeFunction<
405+
String, // Rust → JS argument type
406+
(), // JS return type (void)
407+
String, // JS → Rust argument type (same — identity mapping)
408+
Status, // Error status type
409+
false, // Not CallerHandled (napi manages errors)
410+
false, // Not accepting unknown return types
411+
>,
412+
) -> napi::Result<&Self> {
413+
self.with_inner(|b| {
414+
// Blocking mode is intentional: the guest's print/console.log call
415+
// is synchronous — the guest must wait for the print to complete
416+
// before continuing execution. Unlike host functions (which use
417+
// NonBlocking + oneshot channel for async Promise resolution),
418+
// print is fire-and-forget with no return value to await.
419+
let print_fn = move |msg: String| -> i32 {
420+
let status = callback.call(msg, ThreadsafeFunctionCallMode::Blocking);
421+
if status == Status::Ok {
422+
0
423+
} else {
424+
-1
425+
}
426+
};
427+
b.with_host_print_fn(print_fn.into())
428+
})
429+
}
389430
}
390431

391432
// ── ProtoJSSandbox ───────────────────────────────────────────────────

src/js-host-api/tests/sandbox.test.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,106 @@ describe('Calculator example', () => {
308308
expect(result.result).toBe(4);
309309
});
310310
});
311+
312+
// ── Host print function ──────────────────────────────────────────────
313+
314+
describe('setHostPrintFn', () => {
315+
it('should support method chaining', () => {
316+
const builder = new SandboxBuilder();
317+
const returned = builder.setHostPrintFn(() => {});
318+
expect(returned).toBe(builder);
319+
});
320+
321+
it('should receive console.log output from the guest', async () => {
322+
const messages = [];
323+
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
324+
messages.push(msg);
325+
});
326+
const proto = await builder.build();
327+
const sandbox = await proto.loadRuntime();
328+
sandbox.addHandler(
329+
'handler',
330+
`function handler(event) {
331+
console.log("Hello from guest!");
332+
return event;
333+
}`
334+
);
335+
const loaded = await sandbox.getLoadedSandbox();
336+
await loaded.callHandler('handler', {});
337+
338+
expect(messages.join('')).toContain('Hello from guest!');
339+
});
340+
341+
it('should receive multiple console.log calls', async () => {
342+
const messages = [];
343+
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
344+
messages.push(msg);
345+
});
346+
const proto = await builder.build();
347+
const sandbox = await proto.loadRuntime();
348+
sandbox.addHandler(
349+
'handler',
350+
`function handler(event) {
351+
console.log("first");
352+
console.log("second");
353+
console.log("third");
354+
return event;
355+
}`
356+
);
357+
const loaded = await sandbox.getLoadedSandbox();
358+
await loaded.callHandler('handler', {});
359+
360+
const combined = messages.join('');
361+
expect(combined).toContain('first');
362+
expect(combined).toContain('second');
363+
expect(combined).toContain('third');
364+
});
365+
366+
it('should use the last callback when set multiple times', async () => {
367+
const firstMessages = [];
368+
const secondMessages = [];
369+
const builder = new SandboxBuilder()
370+
.setHostPrintFn((msg) => firstMessages.push(msg))
371+
.setHostPrintFn((msg) => secondMessages.push(msg));
372+
const proto = await builder.build();
373+
const sandbox = await proto.loadRuntime();
374+
sandbox.addHandler(
375+
'handler',
376+
`function handler(event) {
377+
console.log("which callback?");
378+
return event;
379+
}`
380+
);
381+
const loaded = await sandbox.getLoadedSandbox();
382+
await loaded.callHandler('handler', {});
383+
384+
expect(firstMessages.length).toBe(0);
385+
expect(secondMessages.join('')).toContain('which callback?');
386+
});
387+
388+
it('should continue guest execution when callback throws', async () => {
389+
const builder = new SandboxBuilder().setHostPrintFn(() => {
390+
throw new Error('print callback exploded');
391+
});
392+
const proto = await builder.build();
393+
const sandbox = await proto.loadRuntime();
394+
sandbox.addHandler(
395+
'handler',
396+
`function handler(event) {
397+
console.log("this will throw in the callback");
398+
return { survived: true };
399+
}`
400+
);
401+
const loaded = await sandbox.getLoadedSandbox();
402+
const result = await loaded.callHandler('handler', {});
403+
404+
// The JS wrapper catches the throw — guest continues normally
405+
expect(result.survived).toBe(true);
406+
});
407+
408+
it('should throw CONSUMED after build()', async () => {
409+
const builder = new SandboxBuilder();
410+
await builder.build();
411+
expectThrowsWithCode(() => builder.setHostPrintFn(() => {}), 'ERR_CONSUMED');
412+
});
413+
});

0 commit comments

Comments
 (0)