Skip to content

組み込み(Embedding)ガイド

このガイドでは、YanmaJS を Zig ホストアプリケーションに組み込むための API を一通り解説します。すべて Context(yanmajs.Context)というオブジェクトを起点に操作します。

Context のライフサイクル

Context は 1 つの JavaScript 実行環境(グローバルオブジェクト・ヒープ・GC 状態などをまとめたもの)を表します。

const ctx = try Context.init(allocator);
defer ctx.deinit();
  • Context.init(allocator: std.mem.Allocator) !*Context — 指定した Allocator を使う Context を生成します。返り値はヒープ確保された *Context です。
  • Context.deinit(ctx: *Context) voidContext が保持するすべてのリソース(VM・登録済みホスト関数のラッパーなど)を解放します。

メモリ上限付きで初期化する

信頼できない JavaScript コードをサンドボックス実行する場合、メモリ使用量に上限を設けたいことがあります。initWithMemoryLimit を使うと、内部で確保量を追跡するアロケータでラップした Context を作成できます。

const ctx = try Context.initWithMemoryLimit(allocator, 1 * 1024 * 1024); // 1MiB
defer ctx.deinit();

// 上限を超えるアロケーションは error.OutOfMemory になる
_ = ctx.eval("...") catch |err| {
    if (err == error.OutOfMemory) {
        // メモリ上限に達した
    }
};
  • ctx.getMemoryUsage() usize — 現在の使用量(バイト)。init で作成した Context では常に 0
  • ctx.getMemoryLimit() ?usize — 設定された上限。init で作成した場合は null

JavaScript コードを評価する

eval — その場で評価する

もっとも単純な方法は eval です。ソース文字列をパース・コンパイル・実行して結果の Value を返します。

const result = try ctx.eval("1 + 2 * 3");

戻り値の型は EvalError!Value です。EvalError には OutOfMemoryStackOverflowArityMismatchUncaughtExceptionNotCallableRuntimeError などが含まれます。JavaScript 側で例外が投げられた場合は error.UncaughtException になり、エラーハンドリング の節で説明する getPendingException で例外オブジェクトを取り出せます。

compileScript + runScript — 繰り返し実行する

同じスクリプトを何度も実行する場合は、パース・コンパイルを 1 回だけ行い CompiledScript として保持しておき、runScript で繰り返し実行できます。

const script = try ctx.compileScript("1 + 2 * 3");
const result1 = try ctx.runScript(script);
const result2 = try ctx.runScript(script); // 再コンパイルなしで再実行

serializeScript / deserializeScript — バイトコードキャッシュ

コンパイル済みスクリプトはバイトコードとしてシリアライズでき、次回起動時に再パースなしで読み込めます。

const script = try ctx.compileScript(source);
const bytes = try ctx.serializeScript(script);
defer ctx.allocator.free(bytes);

// 保存しておいた bytes を後で読み込む
const loaded = try ctx.deserializeScript(bytes);
const result = try ctx.runScript(loaded);

ES モジュール

evalModule を使うと import/export を含む ES モジュールを評価できます。ホストからモジュールの中身を供給するには ModuleLoader を実装して setModuleLoader で登録します。

const Loader = struct {
    fn load(ptr: *anyopaque, specifier: []const u8) !?[]const u8 {
        _ = ptr;
        if (std.mem.eql(u8, specifier, "constants")) {
            return "export const ANSWER = 42;";
        }
        return null; // 解決できない場合は null
    }
};

var loader_state: u8 = 0;
ctx.setModuleLoader(.{ .ptr = @ptrCast(&loader_state), .load_fn = Loader.load });

_ = try ctx.evalModule("main", "import { ANSWER } from 'constants'; ANSWER;");

ModuleLoader.load_fn は(解決済みの)specifier を受け取り、モジュールのソースコードを返します。ファイルシステムやネットワークから読み込む実装をホスト側で用意してください。相対指定子や自己 import を読み込み元(referrer)基準で正規化したい場合は、任意の resolve_fn を併せて登録します(未設定なら指定子はそのまま使われます)。

グローバル変数の設定・取得

try ctx.setGlobal("answer", yanmajs.makeInt(ctx, 42));
const v = ctx.getGlobal("answer");

const result = try ctx.eval("answer * 2"); // 84
  • setGlobal(name: []const u8, val: Value) !void
  • getGlobal(name: []const u8) Value — 未定義の場合は undefined を返します。

ホスト関数を登録する

JavaScript 側から呼び出せる Zig 関数(ホスト関数)を登録する方法は 2 通りあります。

setFunction — シンプルなケース

this(レシーバ)を必要としない、グローバル関数として登録する場合はこちらを使います。

const addFn: yanmajs.HostFn = struct {
    fn call(c: *Context, args: []const Value) !Value {
        const a = yanmajs.toFloat(args[0]);
        const b = yanmajs.toFloat(args[1]);
        return yanmajs.makeFloat(a + b);
    }
}.call;

try ctx.setFunction("add", addFn);
const result = try ctx.eval("add(3, 4)"); // 7

makeFunction / makeFunctionWithDatathis を扱いたい・Value として使いたいケース

オブジェクトのメソッドとして登録したい場合や、アクセサ(getter/setter)として使いたい場合は makeFunction を使います。こちらは Value(native 関数オブジェクト)を返すので、setProperty/setGlobal/defineProperty に渡して初めて JavaScript 側から到達可能になります。

const getX: yanmajs.HostMethod = struct {
    fn call(c: *Context, this: Value, args: []const Value) !Value {
        _ = args;
        return yanmajs.getProperty(this, "_x");
    }
}.call;

const fn_val = try ctx.makeFunction("getX", getX);
try ctx.setProperty(obj, "getX", fn_val); // メソッドとしてアタッチ

注意: makeFunction/makeFunctionWithData が返す Value は GC のルートに繋がっていません。setProperty/setGlobal/defineProperty で即座にオブジェクトへアタッチするか、protect/HandleScope.pin でルート化してください。アタッチする前に GC を誘発する別のアロケーションを行わないよう注意してください。

個別の userdata を関数に紐付けたい場合は makeFunctionWithData を使います。コールバック内では getFunctionData(callee) でその userdata を取り出せます(calleegetCurrentCallee() で取得可能)。

var state: MyState = .{ ... };
const fn_val = try ctx.makeFunctionWithData("handler", myHandler, @ptrCast(&state));

HostFnHostMethod のシグネチャ

pub const HostFn = *const fn (ctx: *Context, args: []const Value) anyerror!Value;

pub const HostMethod = *const fn (ctx: *Context, this: Value, args: []const Value) anyerror!Value;
  • HostFn はグローバル関数として呼ばれることを想定しており、レシーバを受け取りません。
  • HostMethod はメソッドとして呼ばれた際のレシーバをそのまま this として受け取ります(プリミティブ値の場合もボクシングされません)。グローバル関数として呼ばれた場合の thisundefined になります。

JavaScript 関数を呼び出す

ホスト側から、JavaScript 側で定義された関数を呼び出すには callFunction を使います。

_ = try ctx.eval("function double(x) { return x * 2; }");
const double_fn = ctx.getGlobal("double");

const result = try ctx.callFunction(double_fn, &[_]Value{yanmajs.makeInt(ctx, 21)}); // 42

this(レシーバ)を明示して呼び出したい場合は callFunctionWithReceiver を使います(QuickJS の JS_Call / V8 の Function::Call 相当。メソッドやイベントリスナーの this をホストから指定するケース)。

// method_fn を this = receiver で呼ぶ
const result = try ctx.callFunctionWithReceiver(method_fn, receiver, &[_]Value{});

コンソール出力を差し替える

デフォルトでは console.log/info/warn/error/debug は標準出力に出力されますが、setConsoleHandler でホスト側のコールバックにリダイレクトできます。

const Handler = struct {
    fn write(ptr: ?*anyopaque, level: yanmajs.ConsoleLevel, text: []const u8) void {
        _ = ptr;
        std.debug.print("[{s}] {s}\n", .{ @tagName(level), text });
    }
};

ctx.setConsoleHandler(.{ .write_fn = Handler.write });
// null を渡すと標準出力へのデフォルト動作に戻る

text はコールバックの呼び出し中のみ有効です。後で使い回す場合はホスト側で複製(dupe)してください。

ユーザーデータ(user data)

ホスト側の任意の状態(バインディング全体など)を Context に 1 つだけ紐付けられます。エンジンはこの値を一切解釈せず、GC の対象にもなりません。

var app_state: AppState = .{ ... };
ctx.setUserData(@ptrCast(&app_state));

// HostMethod のコールバック内などから
const state: *AppState = @ptrCast(@alignCast(ctx.getUserData().?));

イベントループ

YanmaJS は setTimeout/setInterval などのタイマー登録を、ホストのイベントループに委譲する形で扱います。

const Hooks = struct {
    fn setTimer(id: u32, delay_ms: u32, is_repeat: bool, userdata: ?*anyopaque) void {
        // ホストのイベントループにタイマーを登録する
    }
    fn clearTimer(id: u32, userdata: ?*anyopaque) void {
        // 対応するタイマーをキャンセルする
    }
};

ctx.setEventLoopHook(.{
    .set_timer = Hooks.setTimer,
    .clear_timer = Hooks.clearTimer,
});

setEventLoopHook を設定しない場合、setTimeout/setInterval は登録と同時に即座にコールバックが実行されます(フォールバック動作)。

エンジンが保持するのは各タイマーの JS コールバックだけで、発火時刻(due)の管理はホストの責務です。set_timer(id, 現在時刻 + delay_ms, is_repeat) を記録しておき、期限が来たら fireTimer(id) を呼びます。内部でコールバックの呼び出しに続けてマイクロタスクのドレイン(drainMicrotasks 相当)も行われます。clearTimeout/clearInterval 済みの id に対する fireTimer は安全な no-op です。

ctx.fireTimer(timer_id);
  • hasPendingTimers() bool — 未発火のタイマーが残っているかどうか。イベントループを終了してよいかの判定に使えます。
  • drainMicrotasks() !void — Promise の .then などで積まれたマイクロタスクキューを処理します。eval/callFunction の後、Promise の解決状態を反映させたい場合に明示的に呼び出してください。

完全な参照実装が 2 つ付属しています。

  • Zig: yanmajs_runtime(yanmajs_runtime/src/event_loop.zigEventLoop)。due 昇順の挿入ソートリストで管理し、native ホスト(CLI/GUI)にも WASM ホストにも使える共通実装として設計されています。現在は CLI(yanmajs_cli/)がこれを使っており、CLI は既定でこのイベントループが有効で、--no-timers オプションを付けるとフックを設定せず従来の同期フォールバック動作になります。
  • C: example/c/event_loop.c。同じパターンの C API 版で、GUI ホストが自前のメインループに組み込む際のひな形です。

EventLoop(yanmajs_runtime)の 2 層構成

yanmajs_runtime.EventLoop は「仮想時計を持つ non-blocking なコア(pump)」と「実クロックで回すブロッキングの便利層(runBlocking)」の 2 層に分かれています。

const runtime = @import("yanmajs_runtime");

var el = runtime.EventLoop.init(allocator);
defer el.deinit();
ctx.setEventLoopHook(el.hook());

// eval より前に、必ず実時刻(または任意の仮想時刻)で seed する。
el.setNow(runtime.monotonicNowMs());
_ = try ctx.eval("setTimeout(() => console.log('hi'), 1000);");

el.runBlocking(ctx); // 次の due までスリープ → pump、を繰り返す(native 専用)
  • pump(ctx, now_ms) ?u64 — non-blocking なコア。now_ms まで仮想時計を進め、マイクロタスクをドレインし、due になったタイマーをすべて発火してから、次の due(なければ null)を返します。実クロックにも仮想クロック(WASM でホストが注入する時刻)にも使えます。
  • runBlocking(ctx) void — native 専用の便利層。pump を実クロック(monotonicNowMs())で繰り返し呼び、次の due までのあいだは raw nanosleep でスリープします。CLI はこれをそのまま使っています。
  • setNow(now_ms) voideval より前に呼ぶ必須の初期化です。EventLoop は自前の仮想時計(now_ms、初期値 0)を持っており、set_timer フックはこの仮想時計を基準に due(絶対時刻)を計算します。setNow で実時刻を seed する前に setTimeout が登録されると、due が 0 + delay_ms という過去寄りの値になり、最初の pump 呼び出し(実際の "今" を渡す)の時点で「もう期限切れ」として即座に発火してしまいます。CLI(yanmajs_cli)は eval の直前に必ず el.setNow(runtime.monotonicNowMs()) を呼ぶことでこれを避けています。
  • WASM サンドボックスのように実クロックを持たないホストは runBlocking を使わず、自前のループ(例: requestAnimationFrame)から pump を直接、外部から渡した now_ms で呼び出します。

実行時間の制限(watchdog)

信頼できないスクリプトの暴走(無限ループ等)を時間で打ち切るには setExecutionTimeLimitMs を使います。以後の eval/evalModule/runScript/callFunction/fireTimer の各呼び出しに、それぞれ独立した壁時計バジェットが課され、超過すると Error("Execution time limit exceeded")(error.RuntimeError)で中断されます。中断後も Context は引き続き利用可能です。

ctx.setExecutionTimeLimitMs(100); // 各 eval/call ごとに 100ms まで

const result = ctx.eval("while (true) {}");
// => error.RuntimeError
const ex = ctx.getPendingException().?; // "Execution time limit exceeded"

ctx.setExecutionTimeLimitMs(null); // 制限を解除

チェックは一定命令数ごと(4096 命令間隔)に行われるため検出には若干の遅れがあります。requestInterrupt とは独立に動作します。時刻源が Linux の CLOCK_MONOTONIC であるため、非 Linux ターゲットでは事実上無効(恒久 no-op)です。詳細は context を、C API での利用例は example/c/watchdog.c を参照してください。

実行の割り込み(interrupt)

長時間実行される、あるいは無限ループするスクリプトを途中で止めたい場合は requestInterrupt/clearInterrupt を使います。これらは atomic フラグ(release/acquire オーダリング)で実装されており、別スレッドから呼び出しても安全です。実行中の VM がインタプリタループの中でフラグを検知すると、実行を中断して例外を送出します。

// 別スレッドから
ctx.requestInterrupt();
// 実行中のスレッド側
const result = ctx.eval("while (true) {}");
// => error.RuntimeError (中断された)
const ex = ctx.getPendingException().?; // "interrupted" を含むメッセージ

clearInterrupt() で保留中の割り込み要求を取り消せます。中断後も Context は引き続き利用可能です。

エラーハンドリング

eval/callFunction/runScript などは JavaScript 例外を error.UncaughtException として返します。実際の例外オブジェクト(Error インスタンスなど)は getPendingException で取得します(取得すると同時にクリアされます)。

const result = ctx.eval("throw new TypeError('bad')");
if (result) |_| {} else |err| {
    if (err == error.UncaughtException) {
        const ex = ctx.getPendingException().?;
        const msg = try yanmajs.toString(ctx, ex);
        defer ctx.allocator.free(msg);
        // msg == "TypeError: bad"
    }
}

ホスト関数の中から JavaScript 側に例外を投げたい場合は throwValue/throwError/throwTypeError/throwRangeError/throwReferenceError/throwSyntaxError を使います。

const validateFn: yanmajs.HostFn = struct {
    fn call(c: *Context, args: []const Value) !Value {
        if (args.len == 0 or !yanmajs.isNumber(args[0])) {
            c.throwTypeError("argument must be a number");
            return yanmajs.makeUndefined();
        }
        return args[0];
    }
}.call;

HostFn/HostMethod が Zig の error を返した場合も、自動的に Error オブジェクトとして JavaScript 側に例外が送出されます。

次のステップ

Value の作成・変換・型判定については Value の扱い方 を参照してください。