Skip to content
Merged
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
10 changes: 10 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ Object.defineProperty(proto, 'nativeCallback', {
},
});

const _importCallback = proto._importCallback;
Object.defineProperty(proto, 'importCallback', {
value: function(func) {
const asyncFunc = isAsyncFunction(func)
? func
: async function(...args) { return func.call(this, ...args); };
return _importCallback.call(this, asyncFunc);
},
});

// Accept an object as the sole argument when defining ext/tla.
for (const func of ['extString', 'extCode', 'tlaString', 'tlaCode']) {
const origFunc = proto[`_${func}`];
Expand Down
130 changes: 127 additions & 3 deletions spec/binding_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,13 @@ describe('binding', () => {
const jsonnet = new Jsonnet().addJpath(`${__dirname}/fixtures`);

let j = await jsonnet.evaluateFile(`${__dirname}/fixtures/utf8.jsonnet`);
expect(j).toBeJSON({ "あ": "あいうえお", "🍔": "🐧"}) ;
expect(j).toBeJSON({ "あ": "あいうえお", "🍔": "🐧" });

j = await jsonnet.evaluateSnippet(`import "utf8.jsonnet"`);
expect(j).toBeJSON({ "あ": "あいうえお", "🍔": "🐧"}) ;
expect(j).toBeJSON({ "あ": "あいうえお", "🍔": "🐧"});

j = await jsonnet.evaluateSnippet(`{"あ": "あいうえお", "🍔": "🐧"}`);
expect(j).toBeJSON({ "あ": "あいうえお", "🍔": "🐧"}) ;
expect(j).toBeJSON({ "あ": "あいうえお", "🍔": "🐧"});
});

it('handles paths in UTF-8', async () => {
Expand Down Expand Up @@ -367,4 +367,128 @@ describe('binding', () => {
]);
});

describe('importCallback', () => {
const path = require('node:path');

it('returns this for method chaining', () => {
const jsonnet = new Jsonnet();
expect(jsonnet.importCallback(() => {})).toBe(jsonnet);
});

it('uses the most recently set callback', async () => {
const jsonnet = new Jsonnet()
.importCallback(() => ({ foundHere: 'x', content: '"first"' }))
.importCallback(() => ({ foundHere: 'x', content: '"second"' }));
const j = await jsonnet.evaluateSnippet('import "x.jsonnet"');
expect(j).toBeJSON('second');
});

it('resolves import with string content', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content: '{ value: 42 }' }));
const j = await jsonnet.evaluateSnippet('import "lib.jsonnet"');
expect(j).toBeJSON({ value: 42 });
});

it('resolves import with Buffer content', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content: Buffer.from('{ value: 42 }') }));
const j = await jsonnet.evaluateSnippet('import "lib.jsonnet"');
expect(j).toBeJSON({ value: 42 });
});

it('resolves import with Uint8Array content', async () => {
const content = new TextEncoder().encode('{ value: 42 }');
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content }));
const j = await jsonnet.evaluateSnippet('import "lib.jsonnet"');
expect(j).toBeJSON({ value: 42 });
});

it('handles UTF-8 content', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content: '{"emoji": "🦔"}' }));
const j = await jsonnet.evaluateSnippet('import "u.jsonnet"');
expect(j).toBeJSON({ emoji: '🦔' });
});

it('resolves importstr', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content: 'hello world' }));
const j = await jsonnet.evaluateSnippet('importstr "text.txt"');
expect(j).toBeJSON('hello world');
});

it('resolves importbin', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content: Buffer.from([1, 2, 255]) }));
const j = await jsonnet.evaluateSnippet('importbin "data.bin"');
expect(j).toBeJSON([1, 2, 255]);
});

it('resolves importbin with NUL bytes in content', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => ({ foundHere: rel, content: Buffer.from([0, 1, 0, 2, 0]) }));
const j = await jsonnet.evaluateSnippet('importbin "data.bin"');
expect(j).toBeJSON([0, 1, 0, 2, 0]);
});

it('receives the directory of the importing file as base', async () => {
const calls = [];
const jsonnet = new Jsonnet()
.importCallback((base, rel) => {
calls.push({ base, rel });
return { foundHere: path.join(base, rel), content: 'null' };
});
await jsonnet.evaluateSnippet('import "lib.jsonnet"', 'dir/snippet.jsonnet');
expect(calls[0]).toEqual({ base: 'dir/', rel: 'lib.jsonnet' });
});

it('uses foundHere as base for nested imports', async () => {
const files = {
'lib/a.jsonnet': 'import "b.jsonnet"',
'lib/b.jsonnet': '{ value: 42 }',
};
const jsonnet = new Jsonnet()
.importCallback((base, rel) => {
const key = path.join(base, rel);
if (!(key in files)) throw new Error(`not found: ${key}`);
return { foundHere: key, content: files[key] };
});
const j = await jsonnet.evaluateSnippet('import "lib/a.jsonnet"');
expect(j).toBeJSON({ value: 42 });
});

it('supports async callback', async () => {
const jsonnet = new Jsonnet()
.importCallback(async (base, rel) => ({ foundHere: rel, content: '"async"' }));
const j = await jsonnet.evaluateSnippet('import "x.jsonnet"');
expect(j).toBeJSON('async');
});

it('propagates synchronous throw as JsonnetError', async () => {
const jsonnet = new Jsonnet()
.importCallback((base, rel) => { throw new Error(`missing: ${rel}`); });
await expectAsync(jsonnet.evaluateSnippet('import "x.jsonnet"'))
.toBeRejectedWithError(JsonnetError,
/missing: x\.jsonnet/);
});

it('propagates async rejection as JsonnetError', async () => {
const jsonnet = new Jsonnet()
.importCallback(async (base, rel) => { throw new Error(`missing: ${rel}`); });
await expectAsync(jsonnet.evaluateSnippet('import "x.jsonnet"'))
.toBeRejectedWithError(JsonnetError,
/missing: x\.jsonnet/);
});

it('takes precedence over addJpath', async () => {
const jsonnet = new Jsonnet()
.addJpath(`${__dirname}/fixtures`)
.importCallback((base, rel) => ({ foundHere: rel, content: '"from callback"' }));
const j = await jsonnet.evaluateSnippet('import "kiwi.libsonnet"');
expect(j).toBeJSON('from callback');
});
});

});
16 changes: 16 additions & 0 deletions src/Jsonnet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <utility>
#include <vector>
#include "JsonValueConverter.hpp"
#include "JsonnetImportCallback.hpp"
#include "JsonnetNativeCallback.hpp"
#include "JsonnetWorker.hpp"
#include "libjsonnet.h"
Expand Down Expand Up @@ -36,6 +37,7 @@ namespace nodejsonnet {
InstanceMethod<&Jsonnet::tlaCode>("_tlaCode"),
InstanceMethod<&Jsonnet::addJpath>("addJpath"),
InstanceMethod<&Jsonnet::nativeCallback>("_nativeCallback"), // See also lib/index.js
InstanceMethod<&Jsonnet::importCallback>("_importCallback"), // See also lib/index.js
});
}

Expand Down Expand Up @@ -203,6 +205,12 @@ namespace nodejsonnet {
return info.This();
}

Napi::Value Jsonnet::importCallback(const Napi::CallbackInfo &info) {
auto const fun = info[0].As<Napi::Function>();
importCallbackParam = ImportCallbackParam{Napi::Persistent(fun)};
return info.This();
}

std::shared_ptr<JsonnetVm> Jsonnet::createVm(Napi::Env const &env) {
auto vm = JsonnetVm::make();

Expand Down Expand Up @@ -254,6 +262,14 @@ namespace nodejsonnet {
params);
}

if(importCallbackParam) {
vm->importCallback(
[callback = std::make_shared<JsonnetImportCallback>(env, importCallbackParam->fun.Value())](
std::shared_ptr<JsonnetVm> vm, std::string const &base, std::string const &rel) {
return callback->call(std::move(vm), base, rel);
});
}

return vm;
}

Expand Down
6 changes: 6 additions & 0 deletions src/Jsonnet.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ namespace nodejsonnet {
std::vector<std::string> params;
};

struct ImportCallbackParam {
Napi::FunctionReference fun;
};

std::optional<unsigned> maxStack, maxTrace;
std::optional<unsigned> gcMinObjects;
std::optional<double> gcGrowthTrigger;
Expand All @@ -30,6 +34,7 @@ namespace nodejsonnet {
std::map<std::string, Variable> ext, tla;
std::vector<std::string> jpath;
std::map<std::string, NativeCallback> nativeCallbacks;
std::optional<ImportCallbackParam> importCallbackParam;
};

class Jsonnet: public Napi::ObjectWrap<Jsonnet>, private JsonnetVmParam {
Expand Down Expand Up @@ -59,6 +64,7 @@ namespace nodejsonnet {
Napi::Value tlaCode(const Napi::CallbackInfo &info);
Napi::Value addJpath(const Napi::CallbackInfo &info);
Napi::Value nativeCallback(const Napi::CallbackInfo &info);
Napi::Value importCallback(const Napi::CallbackInfo &info);

std::shared_ptr<JsonnetVm> createVm(Napi::Env const &env);
};
Expand Down
90 changes: 90 additions & 0 deletions src/JsonnetImportCallback.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT
#include "JsonnetImportCallback.hpp"
#include <cstring>
#include <stdexcept>

namespace nodejsonnet {

JsonnetImportCallback::JsonnetImportCallback(Napi::Env env, Napi::Function fun)
: tsfn{ThreadSafeFunction::New(env, fun, "Jsonnet Import Callback", 0, 1)} {
}

JsonnetImportCallback::~JsonnetImportCallback() {
this->tsfn.Release();
}

JsonnetVm::ImportResult JsonnetImportCallback::call(
std::shared_ptr<JsonnetVm> vm, std::string const &base, std::string const &rel) {
// This function runs in a worker thread and cannot access Node VM.

Payload payload(std::move(vm), base, rel);
tsfn.BlockingCall(&payload);
return payload.getFuture().get();
}

void JsonnetImportCallback::resolveResult(Payload *payload, Napi::Value val) {
auto const obj = val.As<Napi::Object>();
auto const vm = payload->getVm();

auto const foundHereStr = obj.Get("foundHere").As<Napi::String>().Utf8Value();
auto foundHereBuf = vm->allocBuffer(foundHereStr.size() + 1);
std::memcpy(foundHereBuf.get(), foundHereStr.c_str(), foundHereStr.size() + 1);

auto const contentVal = obj.Get("content");
JsonnetVm::Buffer contentBuf;
size_t contentLen;

if(contentVal.IsString()) {
auto const str = contentVal.As<Napi::String>().Utf8Value();
contentBuf = vm->allocBuffer(str.size());
std::memcpy(contentBuf.get(), str.data(), str.size());
contentLen = str.size();
} else {
auto const ta = contentVal.As<Napi::TypedArray>();
contentBuf = vm->allocBuffer(ta.ByteLength());
std::memcpy(contentBuf.get(), static_cast<char *>(ta.ArrayBuffer().Data()) + ta.ByteOffset(),
ta.ByteLength());
contentLen = ta.ByteLength();
}

payload->setResult(JsonnetVm::ImportResult{
std::move(foundHereBuf),
std::move(contentBuf),
contentLen,
});
}

void JsonnetImportCallback::callback(
Napi::Env env, Napi::Function fun, std::nullptr_t *, Payload *payload) {
// This function runs in the Node main thread.

auto const base = Napi::String::New(env, payload->getBase());
auto const rel = Napi::String::New(env, payload->getRel());

auto const result = fun.Call({base, rel});

if(!result.IsPromise()) {
resolveResult(payload, result);
return;
}

auto const on_success = Napi::Function::New(
env,
[](Napi::CallbackInfo const &info) {
resolveResult(static_cast<Payload *>(info.Data()), info[0]);
},
"onSuccess", payload);

auto const on_failure = Napi::Function::New(
env,
[](Napi::CallbackInfo const &info) {
auto const payload = static_cast<Payload *>(info.Data());
auto const error = info[0].ToString();
payload->setError(std::make_exception_ptr(std::runtime_error(error)));
},
"onFailure", payload);

result.As<Napi::Promise>().Then(on_success, on_failure);
}

}
66 changes: 66 additions & 0 deletions src/JsonnetImportCallback.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
#pragma once

#include <future>
#include <memory>
#include <string>
#include <napi.h>
#include "JsonnetVm.hpp"

namespace nodejsonnet {

class JsonnetImportCallback {
public:
JsonnetImportCallback(Napi::Env env, Napi::Function fun);
~JsonnetImportCallback();

JsonnetImportCallback(JsonnetImportCallback const &) = delete;
JsonnetImportCallback &operator=(JsonnetImportCallback const &) = delete;

JsonnetVm::ImportResult call(
std::shared_ptr<JsonnetVm> vm, std::string const &base, std::string const &rel);

private:
struct Payload {
Payload(std::shared_ptr<JsonnetVm> vm, std::string base, std::string rel)
: vm{std::move(vm)}, base{std::move(base)}, rel{std::move(rel)} {
}

std::shared_ptr<JsonnetVm> getVm() const {
return vm;
}
std::string const &getBase() const {
return base;
}
std::string const &getRel() const {
return rel;
}

void setResult(JsonnetVm::ImportResult value) {
result.set_value(std::move(value));
}

void setError(std::exception_ptr e) {
result.set_exception(e);
}

std::future<JsonnetVm::ImportResult> getFuture() {
return result.get_future();
}

private:
std::shared_ptr<JsonnetVm> vm;
std::string base;
std::string rel;
std::promise<JsonnetVm::ImportResult> result;
};

static void resolveResult(Payload *payload, Napi::Value val);
static void callback(Napi::Env env, Napi::Function fun, std::nullptr_t *, Payload *payload);

using ThreadSafeFunction = Napi::TypedThreadSafeFunction<std::nullptr_t, Payload, callback>;

ThreadSafeFunction tsfn;
};

}
Loading
Loading