Skip to content

Commit 54b4d31

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 54b4d31

3 files changed

Lines changed: 142 additions & 0 deletions

File tree

src/js-host-api/lib.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ for (const method of [
201201
'setScratchSize',
202202
'setInputBufferSize',
203203
'setOutputBufferSize',
204+
'setHostPrintFn',
204205
]) {
205206
const orig = SandboxBuilder.prototype[method];
206207
if (!orig) throw new Error(`Cannot wrap missing method: SandboxBuilder.${method}`);

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,44 @@ 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+
/// @param callback - `(message: string) => void` — called for each print
396+
/// @returns this (for chaining)
397+
/// @throws If the builder has already been consumed by `build()`
398+
#[napi]
399+
pub fn set_host_print_fn(
400+
&self,
401+
#[napi(ts_arg_type = "(message: string) => void")] callback: ThreadsafeFunction<
402+
String, // Rust → JS argument type
403+
(), // JS return type (void)
404+
String, // JS → Rust argument type (same — identity mapping)
405+
Status, // Error status type
406+
false, // Not CallerHandled (napi manages errors)
407+
false, // Not accepting unknown return types
408+
>,
409+
) -> napi::Result<&Self> {
410+
self.with_inner(|b| {
411+
// Blocking mode is intentional: the guest's print/console.log call
412+
// is synchronous — the guest must wait for the print to complete
413+
// before continuing execution. Unlike host functions (which use
414+
// NonBlocking + oneshot channel for async Promise resolution),
415+
// print is fire-and-forget with no return value to await.
416+
let print_fn = move |msg: String| -> i32 {
417+
let status = callback.call(msg, ThreadsafeFunctionCallMode::Blocking);
418+
if status == Status::Ok {
419+
0
420+
} else {
421+
-1
422+
}
423+
};
424+
b.with_host_print_fn(print_fn.into())
425+
})
426+
}
389427
}
390428

391429
// ── 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+
// Guest should continue execution even if the print callback throws
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)