Skip to content

Commit 6d4c32a

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 80f4b55 commit 6d4c32a

3 files changed

Lines changed: 192 additions & 1 deletion

File tree

src/js-host-api/lib.js

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

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

213237
module.exports = native;

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,56 @@ impl SandboxBuilderWrapper {
401401
inner: Arc::new(Mutex::new(Some(proto_sandbox))),
402402
})
403403
}
404+
405+
/// Set a callback that receives guest `console.log` / `print` output.
406+
///
407+
/// Without this, guest print output is silently discarded. The callback
408+
/// receives each print message as a string.
409+
///
410+
/// If the callback throws, the exception is caught by the JS wrapper
411+
/// (`lib.js`) and logged to `console.error`. Guest execution continues.
412+
///
413+
/// @param callback - `(message: string) => void` — called for each print
414+
/// @returns this (for chaining)
415+
/// @throws If the builder has already been consumed by `build()`
416+
#[napi]
417+
pub fn set_host_print_fn(
418+
&self,
419+
#[napi(ts_arg_type = "(message: string) => void")] callback: ThreadsafeFunction<
420+
String, // Rust → JS argument type
421+
(), // JS return type (void)
422+
String, // JS → Rust argument type (same — identity mapping)
423+
Status, // Error status type
424+
false, // Not CallerHandled (napi manages errors)
425+
false, // Not accepting unknown return types
426+
>,
427+
) -> napi::Result<&Self> {
428+
self.with_inner(|b| {
429+
// Blocking mode ensures the TSFN dispatch completes before the
430+
// native call returns, but the JS wrapper defers the user callback
431+
// via a Promise microtask — so user code may run after guest
432+
// execution resumes. From the guest's perspective, print is
433+
// effectively fire-and-forget with no return value to await.
434+
// Unlike host functions (which use NonBlocking + oneshot channel
435+
// for async Promise resolution), print has no result path.
436+
//
437+
// **Reentrancy note:** The print callback ultimately runs while
438+
// the sandbox Mutex is held (inside `call_handler`'s
439+
// `spawn_blocking`). Calling Hyperlight APIs that acquire the
440+
// same lock from within the callback (e.g. `snapshot()`,
441+
// `restore()`, `unload()`) will deadlock. Keep print callbacks
442+
// simple — logging only.
443+
let print_fn = move |msg: String| -> i32 {
444+
let status = callback.call(msg, ThreadsafeFunctionCallMode::Blocking);
445+
if status == Status::Ok {
446+
0
447+
} else {
448+
-1
449+
}
450+
};
451+
b.with_host_print_fn(print_fn.into())
452+
})
453+
}
404454
}
405455

406456
// ── ProtoJSSandbox ───────────────────────────────────────────────────

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

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Basic sandbox functionality tests
2-
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { describe, it, expect, beforeEach, vi } from 'vitest';
33
import { SandboxBuilder } from '../lib.js';
44
import { expectThrowsWithCode, expectRejectsWithCode } from './test-helpers.js';
55

@@ -445,3 +445,120 @@ describe('LoadedJSSandbox.unload()', () => {
445445
expectThrowsWithCode(() => loaded.lastCallStats, 'ERR_CONSUMED');
446446
});
447447
});
448+
449+
// ── Host print function ──────────────────────────────────────────────
450+
451+
describe('setHostPrintFn', () => {
452+
it('should support method chaining', () => {
453+
const builder = new SandboxBuilder();
454+
const returned = builder.setHostPrintFn(() => {});
455+
expect(returned).toBe(builder);
456+
});
457+
458+
it('should receive console.log output from the guest', async () => {
459+
const messages = [];
460+
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
461+
messages.push(msg);
462+
});
463+
const proto = await builder.build();
464+
const sandbox = await proto.loadRuntime();
465+
sandbox.addHandler(
466+
'handler',
467+
`function handler(event) {
468+
console.log("Hello from guest!");
469+
return event;
470+
}`
471+
);
472+
const loaded = await sandbox.getLoadedSandbox();
473+
await loaded.callHandler('handler', {});
474+
// Flush microtasks — the print wrapper defers via Promise
475+
await new Promise((r) => setTimeout(r, 0));
476+
477+
expect(messages.join('')).toContain('Hello from guest!');
478+
});
479+
480+
it('should receive multiple console.log calls', async () => {
481+
const messages = [];
482+
const builder = new SandboxBuilder().setHostPrintFn((msg) => {
483+
messages.push(msg);
484+
});
485+
const proto = await builder.build();
486+
const sandbox = await proto.loadRuntime();
487+
sandbox.addHandler(
488+
'handler',
489+
`function handler(event) {
490+
console.log("first");
491+
console.log("second");
492+
console.log("third");
493+
return event;
494+
}`
495+
);
496+
const loaded = await sandbox.getLoadedSandbox();
497+
await loaded.callHandler('handler', {});
498+
// Flush microtasks
499+
await new Promise((r) => setTimeout(r, 0));
500+
501+
const combined = messages.join('');
502+
expect(combined).toContain('first');
503+
expect(combined).toContain('second');
504+
expect(combined).toContain('third');
505+
});
506+
507+
it('should use the last callback when set multiple times', async () => {
508+
const firstMessages = [];
509+
const secondMessages = [];
510+
const builder = new SandboxBuilder()
511+
.setHostPrintFn((msg) => firstMessages.push(msg))
512+
.setHostPrintFn((msg) => secondMessages.push(msg));
513+
const proto = await builder.build();
514+
const sandbox = await proto.loadRuntime();
515+
sandbox.addHandler(
516+
'handler',
517+
`function handler(event) {
518+
console.log("which callback?");
519+
return event;
520+
}`
521+
);
522+
const loaded = await sandbox.getLoadedSandbox();
523+
await loaded.callHandler('handler', {});
524+
525+
expect(firstMessages.length).toBe(0);
526+
expect(secondMessages.join('')).toContain('which callback?');
527+
});
528+
529+
it('should continue guest execution when callback throws', async () => {
530+
const builder = new SandboxBuilder().setHostPrintFn(() => {
531+
throw new Error('print callback exploded');
532+
});
533+
const proto = await builder.build();
534+
const sandbox = await proto.loadRuntime();
535+
sandbox.addHandler(
536+
'handler',
537+
`function handler(event) {
538+
console.log("this will throw in the callback");
539+
return { survived: true };
540+
}`
541+
);
542+
const loaded = await sandbox.getLoadedSandbox();
543+
544+
// Spy on console.error to suppress noise and verify the error is logged
545+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
546+
try {
547+
const result = await loaded.callHandler('handler', {});
548+
// Flush microtasks — the print wrapper defers via Promise
549+
await new Promise((r) => setTimeout(r, 0));
550+
551+
// The JS wrapper catches the throw — guest continues normally
552+
expect(result.survived).toBe(true);
553+
expect(errorSpy).toHaveBeenCalledWith('Host print callback threw:', expect.any(Error));
554+
} finally {
555+
errorSpy.mockRestore();
556+
}
557+
});
558+
559+
it('should throw CONSUMED after build()', async () => {
560+
const builder = new SandboxBuilder();
561+
await builder.build();
562+
expectThrowsWithCode(() => builder.setHostPrintFn(() => {}), 'ERR_CONSUMED');
563+
});
564+
});

0 commit comments

Comments
 (0)