Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/swc-sourcemaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godot-js/editor": patch
---

**Feature:** Emit inline data-URL sourcemaps for transpiled `.ts` modules and remap runtime `Error.stack` frames back to their TypeScript source positions.
5 changes: 5 additions & 0 deletions .changeset/swc-transpiler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godot-js/editor": patch
---

**Feature:** Optionally embed an SWC-based TypeScript transpiler as a Rust staticlib and load `.ts` modules directly by transpiling them in-process. Opt-in via the `use_typescript_transpiler=yes` build flag (off by default; the default build is unchanged and needs no Rust toolchain).
73 changes: 73 additions & 0 deletions SCsub
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,79 @@ module_obj = []
print(f"godot engine: {version_info['major']}.{version_info['minor']}")
check("regex" in env.module_list, "Currently, the 'regex' module is required for using GodotJS. Please rebuild the engine with it enabled.")

# --- transpiler-ffi: SWC-based TypeScript transpiler (Rust staticlib) ----------
# Compiles the crate under transpiler-ffi/ and exposes the FFI header to all
# GodotJS C++ sources. Owns the `godotjs_transpile_ts()` entry point used by
# the runtime loader to turn .ts source into CommonJS at load time.
def rust_target_triple_for(platform, arch):
return {
("macos", "arm64"): "aarch64-apple-darwin",
("macos", "x86_64"): "x86_64-apple-darwin",
("linux", "x86_64"): "x86_64-unknown-linux-gnu",
("linux", "arm64"): "aarch64-unknown-linux-gnu",
("windows", "x86_64"): "x86_64-pc-windows-msvc",
("windows", "arm64"): "aarch64-pc-windows-msvc",
}.get((platform, arch))

def build_transpiler_ffi():
triple = rust_target_triple_for(jsb_platform, jsb_arch)
check(triple is not None,
f"transpiler-ffi: no Rust target triple mapped for {jsb_platform}/{jsb_arch}")

cargo = shutil.which("cargo")
check(cargo is not None,
"transpiler-ffi: 'cargo' not found on PATH — install the Rust toolchain (rustup.rs)")

manifest = "transpiler-ffi/Cargo.toml"
print_info(f"transpiler-ffi: cargo build --release --target {triple}")
rc = subprocess.run(
[cargo, "build", "--release", "--manifest-path", manifest, "--target", triple],
check=False,
)
check(rc.returncode == 0,
f"transpiler-ffi: cargo build failed (exit {rc.returncode})")

lib_name = "godotjs_transpiler_ffi.lib" if jsb_platform == "windows" else "libgodotjs_transpiler_ffi.a"
lib_path = f"transpiler-ffi/target/{triple}/release/{lib_name}"
check(os.path.exists(lib_path),
f"transpiler-ffi: expected staticlib at {lib_path} after cargo build")
return lib_path

# transpiler-ffi is opt-in (scons use_typescript_transpiler=yes) and only builds
# for native desktop targets that have a Rust target triple mapped (macos/linux/
# windows). With the flag off (the default) no Rust toolchain is needed and the
# build is unchanged. web/android/ios have no triple here: they ship
# pre-transpiled .js (export-time pre-transpile) and use their own JS engine, so
# they skip the staticlib and compile out the runtime .ts load path via the
# JSB_WITH_TYPESCRIPT_TRANSPILER define below.
want_transpiler = env["use_typescript_transpiler"]
has_transpiler = want_transpiler and rust_target_triple_for(jsb_platform, jsb_arch) is not None
if want_transpiler and not has_transpiler:
print_warning(f"transpiler-ffi: no Rust target triple mapped for {jsb_platform}/{jsb_arch}; building without the TypeScript transpiler (use export-time pre-transpiled .js)")
env_jsb.Append(CPPDEFINES=[("JSB_WITH_TYPESCRIPT_TRANSPILER", 1 if has_transpiler else 0)])
if has_transpiler:
transpiler_ffi_lib = build_transpiler_ffi()
env_jsb.Append(CPPPATH=[f"#modules/{module_name}/transpiler-ffi/include"])
env.Append(LIBS=[File(transpiler_ffi_lib)])

# Rust staticlibs may pull in platform frameworks/libs that Godot doesn't
# already link. SWC's tree is mostly pure Rust; these cover the OS-specific
# system bits its transitive deps reach for. Refine if the linker complains.
#
# The --allow-multiple-definition / /FORCE:MULTIPLE flags below cover a
# specific clash: our staticlib and Godot's prebuilt accesskit staticlib both
# bundle rust_eh_personality (and a few other libstd symbols) from rustc's
# libstd, compiled against different rustc versions. GNU ld and MSVC link.exe
# error on the duplicate; macOS ld64 silently picks one. The clash is benign
# — both copies are ABI-compatible personality routines.
if jsb_platform == "macos":
env.Append(LINKFLAGS=["-framework", "CoreFoundation", "-framework", "Security"])
elif jsb_platform == "linux":
env.Append(LINKFLAGS=["-Wl,--allow-multiple-definition"])
elif jsb_platform == "windows":
env.Append(LINKFLAGS=["userenv.lib", "ntdll.lib", "bcrypt.lib", "/FORCE:MULTIPLE"])
# -------------------------------------------------------------------------------

# Build JS files before generating
if not env["skip_js_runtime"]:
pnpm_command = shutil.which("pnpm")
Expand Down
48 changes: 48 additions & 0 deletions bridge/jsb_environment.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,51 @@ namespace jsb
const String str = impl::Helper::to_string_without_side_effect(isolate, message.GetValue());
JSB_LOG(Error, "unhandled promise rejection: %s", str);
}

#if JSB_WITH_V8
// Called by V8 the first time `error.stack` is read. V8 12.4 in this build
// doesn't auto-apply source maps to the runtime Error.stack string (DevTools
// is fine because it uses the sourcemap separately). We build the default
// V8 stack format ourselves via each CallSite's `toString`, then pass the
// result through SourceMapCache which rewrites .ts/.js positions back to
// the original source thanks to the inline sourcemap fed in at transpile
// time. If anything goes wrong, return empty so V8 falls back to its
// built-in (raw) formatter.
v8::MaybeLocal<v8::Value> PrepareStackTraceCallback_(v8::Local<v8::Context> context,
v8::Local<v8::Value> error,
v8::Local<v8::Array> sites)
{
v8::Isolate* isolate = context->GetIsolate();
Environment* env = Environment::wrap(isolate);

String stack = impl::Helper::to_string_without_side_effect(isolate, error);

const v8::Local<v8::String> to_string_key = impl::Helper::new_string_ascii(isolate, "toString");
const uint32_t count = sites->Length();
for (uint32_t i = 0; i < count; ++i)
{
v8::Local<v8::Value> site_val;
if (!sites->Get(context, i).ToLocal(&site_val) || !site_val->IsObject()) continue;
const v8::Local<v8::Object> site = site_val.As<v8::Object>();

v8::Local<v8::Value> to_string_val;
if (!site->Get(context, to_string_key).ToLocal(&to_string_val) || !to_string_val->IsFunction()) continue;
const v8::Local<v8::Function> to_string_fn = to_string_val.As<v8::Function>();

v8::Local<v8::Value> frame_val;
if (!to_string_fn->Call(context, site, 0, nullptr).ToLocal(&frame_val)) continue;

stack += "\n at " + impl::Helper::to_string(isolate, frame_val);
}

if (env)
{
stack = env->get_source_map_cache().process_source_position(stack);
}

return impl::Helper::new_string(isolate, stack).As<v8::Value>();
}
#endif
}

Environment::Environment(const CreateParams& p_params)
Expand All @@ -273,6 +318,9 @@ namespace jsb
isolate_ = v8::Isolate::New(create_params);
isolate_->SetData(kIsolateEmbedderData, this);
isolate_->SetPromiseRejectCallback(PromiseRejectCallback_);
#if JSB_WITH_V8
isolate_->SetPrepareStackTraceCallback(PrepareStackTraceCallback_);
#endif
#if JSB_PRINT_GC_TIME
isolate_->AddGCPrologueCallback(&OnPreGCCallback);
isolate_->AddGCEpilogueCallback(&OnPostGCCallback);
Expand Down
158 changes: 156 additions & 2 deletions bridge/jsb_module_resolver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,129 @@
#include "jsb_environment.h"

#include "../internal/jsb_path_util.h"
#include "core/crypto/crypto_core.h"

#if JSB_WITH_TYPESCRIPT_TRANSPILER
extern "C" {
#include "godotjs_transpiler.h"
}
#endif

namespace jsb
{
namespace
{
#if JSB_WITH_TYPESCRIPT_TRANSPILER
// Transpile a TypeScript source into the same `(function(exports,require,module,__filename,__dirname){ ... \n})`
// wrapper that `read_all_bytes_with_shebang` produces for `.js` sources.
// When SWC produced a sourcemap, the `//# sourceMappingURL=<data-url>`
// comment is inserted on its own line just before the closing `})`.
// Keeping the comment INSIDE the wrapper (not before it) preserves the
// body's line numbering relative to wrapped positions, so V8's stack
// frames map cleanly through the sourcemap for everything past line 0.
// The decoded sourcemap is also fed into Environment::source_map_cache_
// so the V8 PrepareStackTrace callback can remap runtime Error.stack
// frames back to .ts positions (V8 12.4 doesn't auto-apply source maps
// to Error.stack, only DevTools).
bool transpile_typescript_to_wrapped_source(Environment* p_env,
const String& p_source_url,
const internal::ISourceReader& p_reader,
Vector<uint8_t>& o_wrapped,
String& r_error)
{
static constexpr char header[] = "(function(exports,require,module,__filename,__dirname){";
static constexpr char footer[] = "\n})";
static constexpr char sourcemap_prefix[] = "\n//# sourceMappingURL=";

const uint64_t raw_len = p_reader.get_length();
Vector<uint8_t> raw;
raw.resize((int) raw_len);
if (raw_len > 0 && p_reader.get_buffer(raw.ptrw(), raw_len) != raw_len)
{
r_error = "failed to read typescript source";
return false;
}

// SWC's `sources` entry becomes the path SourceMapCache exposes when
// remapping stacks. Use the same string V8 will report in frames so
// a single key lookup hits in the cache (otherwise the bridge would
// pass `res://…` while V8 reports the absolute file path).
const CharString filename_utf8 = p_source_url.utf8();
GodotJSTranspileResult* result = godotjs_transpile_ts(
raw.ptr(), (size_t) raw_len,
(const uint8_t*) filename_utf8.get_data(), (size_t) filename_utf8.length(),
0
);

if (!result)
{
r_error = "godotjs_transpile_ts returned null";
return false;
}

if (result->error)
{
r_error = String::utf8((const char*) result->error, (int) result->error_len);
godotjs_free_transpile_result(result);
return false;
}

const size_t header_size = ::std::size(header) - 1;
const size_t code_len = result->code_len;
const size_t sm_len = result->sourcemap ? result->sourcemap_len : 0;
const size_t sm_prefix_size = sm_len > 0 ? ::std::size(sourcemap_prefix) - 1 : 0;

o_wrapped.resize((int) (header_size + code_len + sm_prefix_size + sm_len + ::std::size(footer)));
size_t offset = 0;
memcpy(o_wrapped.ptrw() + offset, header, header_size);
offset += header_size;
if (code_len > 0)
{
memcpy(o_wrapped.ptrw() + offset, result->code, code_len);
offset += code_len;
}
if (sm_len > 0)
{
memcpy(o_wrapped.ptrw() + offset, sourcemap_prefix, sm_prefix_size);
offset += sm_prefix_size;
memcpy(o_wrapped.ptrw() + offset, result->sourcemap, sm_len);
offset += sm_len;
}
memcpy(o_wrapped.ptrw() + offset, footer, ::std::size(footer)); // includes trailing zero

// Pull the base64 payload out of `data:application/json;…;base64,<b64>`
// and feed the decoded JSON into the source map cache so runtime stack
// remap can find it by the same key V8 will report.
if (p_env && sm_len > 0)
{
const uint8_t* url = result->sourcemap;
size_t url_len = result->sourcemap_len;
const uint8_t* comma = (const uint8_t*) memchr(url, ',', url_len);
if (comma)
{
const size_t b64_off = (comma - url) + 1;
if (url_len > b64_off)
{
const uint8_t* b64_ptr = url + b64_off;
const size_t b64_len = url_len - b64_off;
Vector<uint8_t> json_buf;
json_buf.resize((int) (b64_len * 3 / 4 + 4));
size_t out_len = 0;
if (CryptoCore::b64_decode(json_buf.ptrw(), (size_t) json_buf.size(), &out_len, b64_ptr, b64_len) == OK && out_len > 0)
{
const String json = String::utf8((const char*) json_buf.ptr(), (int) out_len);
p_env->get_source_map_cache().feed(p_source_url, json);
}
}
}
}

godotjs_free_transpile_result(result);
return true;
}
#endif
}

namespace
{
// the cost of copy return is acceptable since Vector copy constructor is by-reference under the hood
Expand Down Expand Up @@ -116,6 +236,19 @@ namespace jsb
return true;
}

#if JSB_WITH_TYPESCRIPT_TRANSPILER
// try .ts (source-of-truth — probed after .js so an existing emitted
// artefact still wins; only with the embedded transpiler, which loads
// it in-process). Without the transpiler this probe is compiled out so
// resolution is identical to a build that never knew about .ts.
const String ts_path = internal::PathUtil::extends_with(p_module_id, "." JSB_TYPESCRIPT_EXT);
if (FileAccess::exists(ts_path))
{
o_path = ts_path;
return true;
}
#endif

// try .cjs
const String cjs_path = internal::PathUtil::extends_with(p_module_id, "." JSB_COMMONJS_EXT);
if (FileAccess::exists(cjs_path))
Expand Down Expand Up @@ -556,8 +689,9 @@ namespace jsb
return load_as_json(p_env, p_module, p_asset_path, source, len);
}

const bool is_typescript = p_asset_path.ends_with("." JSB_TYPESCRIPT_EXT);
#if JSB_DEBUG
if (!internal::PathUtil::is_recognized_javascript_extension(p_asset_path))
if (!is_typescript && !internal::PathUtil::is_recognized_javascript_extension(p_asset_path))
{
JSB_LOG(Warning, "%s is suspiciously not JS", p_asset_path);
}
Expand All @@ -571,7 +705,27 @@ namespace jsb

const String source_url = p_reader.get_source_url();
Vector<uint8_t> source;
const size_t len = read_all_bytes_with_shebang(p_reader, source);
size_t len;
if (is_typescript)
{
#if JSB_WITH_TYPESCRIPT_TRANSPILER
String transpile_error;
if (!transpile_typescript_to_wrapped_source(p_env, source_url, p_reader, source, transpile_error))
{
JSB_LOG(Error, "ts transpile failed for %s: %s", p_asset_path, transpile_error);
impl::Helper::throw_error(isolate, jsb_format("ts transpile failed for %s: %s", p_asset_path, transpile_error));
return false;
}
len = source.size() - 1; // exclude trailing zero
#else
impl::Helper::throw_error(isolate, jsb_format("cannot load .ts at runtime on this platform (no embedded transpiler); pre-transpile to .js at export time: %s", p_asset_path));
return false;
#endif
}
else
{
len = read_all_bytes_with_shebang(p_reader, source);
}
jsb_check((size_t)(int)len == len);

// source evaluator (the module protocol)
Expand Down
1 change: 1 addition & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def get_opts(platform):
return [
BoolVariable("skip_js_runtime", "Skip building GodotJS JavaScript runtime files", False),
BoolVariable("use_typescript", "Build with typescript support (enabled by default)", True),
BoolVariable("use_typescript_transpiler", "Embed the SWC TypeScript transpiler to load .ts directly (requires the Rust toolchain; desktop platforms only)", False),
BoolVariable("use_jsc", "Prefer to use JavaScriptCore (only for macos and ios)", False),
BoolVariable("use_quickjs", "Prefer to use QuickJS rather than the default VM on the current platform", False),
BoolVariable("use_quickjs_ng", "Prefer to use QuickJS-NG rather than the default VM on the current platform", False),
Expand Down
7 changes: 6 additions & 1 deletion impl/v8/jsb_v8_helper.h
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,12 @@ namespace jsb::impl
v8::Local<v8::Value> filename_value = v8::String::NewFromUtf8(isolate, filename.utf8().ptr(), v8::NewStringType::kNormal, filename.length()).ToLocalChecked();
v8::Local<v8::Value> source_map_url_value;

if (source_map_base_url.length() > 0)
// For `.ts` sources the embedded SWC transpiler already emits
// an inline `//# sourceMappingURL=data:…` URL inside the wrapped
// source; setting ScriptOrigin's source_map_url here would shadow
// it with the debugger's HTTP `.map` URL (which doesn't exist).
const bool prefer_inline_source_map = p_source_origin.ends_with(".ts");
if (source_map_base_url.length() > 0 && !prefer_inline_source_map)
{
String source_map_url = source_map_base_url + (source_map_base_url.ends_with("/") ? filename : '/' + filename) + ".map";
source_map_url_value = v8::String::NewFromUtf8(isolate, source_map_url.utf8().ptr(), v8::NewStringType::kNormal, source_map_url.length()).ToLocalChecked();
Expand Down
Loading