From 5548fb30e5b8a0f6e7733f09ae47a035a99b6ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=A6n=20Hansen?= Date: Mon, 22 Jul 2024 13:58:05 +0200 Subject: [PATCH] WIP building wasm binding --- .gitignore | 1 + contrib/building.md | 22 + integration-tests/tests/src/typings.d.ts | 4 + .../realm/bindgen/browser_opt_in_spec.yml | 519 ++++++++++ packages/realm/bindgen/src/templates/wasm.ts | 935 ++++++++++++++++++ packages/realm/binding/wasm/CMakeLists.txt | 64 ++ packages/realm/binding/wasm/platform.cpp | 62 ++ packages/realm/package.json | 29 +- packages/realm/src/scripts/build/cli.ts | 27 +- packages/realm/src/scripts/build/wasm.ts | 78 ++ 10 files changed, 1729 insertions(+), 12 deletions(-) create mode 100644 packages/realm/bindgen/browser_opt_in_spec.yml create mode 100644 packages/realm/bindgen/src/templates/wasm.ts create mode 100644 packages/realm/binding/wasm/CMakeLists.txt create mode 100644 packages/realm/binding/wasm/platform.cpp create mode 100644 packages/realm/src/scripts/build/wasm.ts diff --git a/.gitignore b/.gitignore index 06fb301538..0ee47de18f 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ coverage/ /packages/realm/binding/build/ /packages/realm/binding/android/build/ /packages/realm/binding/node/build/ +/packages/realm/binding/wasm/build/ /packages/realm/prebuilds/ /packages/realm/binding/android/src/main/java/io/realm/react/Version.java /packages/realm/binding/android/src/main/jniLibs/ diff --git a/contrib/building.md b/contrib/building.md index 96b28615a9..f4337aee21 100644 --- a/contrib/building.md +++ b/contrib/building.md @@ -320,6 +320,28 @@ npm install path/to/realm-js/packages/realm > [!TIP] > To run any of the `"scripts"` commands from one of the `package.json` files directly from the root, use the `"name"` value from the target `package.json` as such: `npm run --workspace `. +### Building for WASM (Browsers) + +Follow the steps to install the Emscripten SDK (emsdk) on https://emscripten.org/docs/getting_started/downloads.html. + +1. Clone the repository and enter the directory + ``` + git clone https://github.com/emscripten-core/emsdk.git + cd emsdk + ``` +2. Pull, install and activate the latest version + ``` + git pull + ./emsdk install latest + ./emsdk activate latest + ``` +3. Follow the instructions to setup environment variables for your shell (ex add this to your `~/.zshenv`) + ```bash + # Emscripten SDK + export EMSDK_QUIET=1 + source "$HOME//emsdk_env.sh" + ``` + ### Cleaning up build files If you need to clean up build files and other untracked files (except for `node_modules` directories), run the following command from the root directory: diff --git a/integration-tests/tests/src/typings.d.ts b/integration-tests/tests/src/typings.d.ts index 5ff4744775..6022be7898 100644 --- a/integration-tests/tests/src/typings.d.ts +++ b/integration-tests/tests/src/typings.d.ts @@ -107,6 +107,10 @@ type KnownEnvironment = { * React native specific variable injected by the runner, to signal if the tests are ran by the legacy chrome debugger (i.e. in a browser). * @deprecated Since we no longer support the legacy chrome debugger. */ chromeDebugging?: true; + /** + * Browser specific variable injected by the runner, to signal if we're running inside a Browser with WebAssembly. + */ + browser?: true; }; type Environment = KnownEnvironment & Record; diff --git a/packages/realm/bindgen/browser_opt_in_spec.yml b/packages/realm/bindgen/browser_opt_in_spec.yml new file mode 100644 index 0000000000..952574848f --- /dev/null +++ b/packages/realm/bindgen/browser_opt_in_spec.yml @@ -0,0 +1,519 @@ +# yaml-language-server: $schema=vendor/realm-core/bindgen/generated/opt-in-spec.schema.json + +# -------------------- +# Description of file: +# -------------------- +# List all the unique names of what to opt in to from the general spec. +# +# The following can be listed: +# * `classes` and their `methods` +# * Methods, static methods, constructors, and properties in the general `spec.yml` +# should all be listed in this opt-in list as `methods`. +# * `records` and their `fields`` +# +# If all methods in a class, or all fields of a property, are opted out of, +# the entire class/property should be removed. + +records: + Property: + fields: + - name + - public_name + - type + - object_type + - link_origin_property_name + - is_primary + - is_indexed + - column_key + + ObjectSchema: + fields: + - name + - persisted_properties + - computed_properties + - primary_key + - table_key + - table_type + + RealmConfig: + fields: + - path + - cache + - encryption_key + - fifo_files_fallback_path + - in_memory + - schema + - schema_version + - schema_mode + - disable_format_upgrade + - sync_config + - force_sync_history + - migration_function + - initialization_function + - should_compact_on_launch_function + + UserIdentity: + fields: + - id + - provider_type + + UserAPIKey: + fields: + - id + - key + - name + - disabled + + SyncConfig: + fields: + - user + - partition_value + - stop_policy + - flx_sync_requested + - error_handler + - custom_http_headers + - client_validate_ssl + - ssl_trust_certificate_path + - ssl_verify_callback + - cancel_waits_on_nonfatal_error + + SyncSubscription: + fields: + - id + - created_at + - updated_at + - name + - object_class_name + - query_string + + ObjectChangeSet: + fields: + - is_deleted + - changed_columns + + CollectionChangeSet: + fields: + - deletions + - insertions + - modifications + - modifications_new + + DictionaryChangeSet: + fields: + - deletions + - insertions + - modifications + + BindingContext: + fields: + - did_change + - before_notify + - schema_did_change + + SyncClientConfig: + fields: + - base_file_path + - metadata_mode + - user_agent_binding_info + + SyncError: + fields: + - system_error + - is_fatal + - simple_message + - logURL + - user_info + - is_client_reset_requested + - compensating_writes_info + + Request: + fields: + - method + - url + - timeout_ms + - headers + - body + + Response: + fields: + - http_status_code + - custom_status_code + - headers + - body + + DeviceInfo: + fields: + - platform_version + - sdk_version + - sdk + # - cpu_arch + - device_name + - device_version + - framework_name + - framework_version + + AppConfig: + fields: + - app_id + - base_url + - local_app_name + - local_app_version + - default_request_timeout_ms + - device_info + + CompensatingWriteErrorInfo: + fields: + - object_name + - reason + - primary_key + +classes: + ###################### + # FROM JS EXTRA SPEC # + ###################### + + # These JsPlatformHelpers are used for React Native. + JsPlatformHelpers: + methods: + - default_realm_file_directory + - ensure_directory_exists_for_file + - copy_bundled_realm_files + - remove_realm_files_from_directory + - remove_file + - remove_directory + - get_cpu_arch + + WeakSyncSession: + methods: + - weak_copy_of + - raw_dereference + + ##################### + # FROM GENERAL SPEC # + ##################### + + Helpers: + methods: + - get_table + - get_keypath_mapping + - results_append_query + - make_object_notifier + - set_binding_context + - get_or_create_object_with_primary_key + # - make_network_transport + - delete_data_for_object + - is_empty_realm + - base64_decode + - make_logger_factory + - make_logger + - simulate_sync_error + - consume_thread_safe_reference_to_shared_realm + - file_exists + - erase_subscription + - get_results_description + # - feed_buffer + - make_ssl_verify_callback + + Logger: + methods: + - set_default_logger + - set_default_level_threshold + + ConstTableRef: + methods: + - get_key + - get_column_type + - get_link_target + - get_object + - try_get_object + - query + - find_primary_key + + TableRef: + methods: + - create_object + - remove_object + - get_link_target + - clear + + Obj: + methods: + - is_valid + - get_table + - get_key + - get_any + - set_any + - get_linked_object + - get_backlink_count + - get_backlink_view + - create_and_set_linked_object + + Timestamp: + methods: + - make + - get_seconds + - get_nanoseconds + + ObjLink: + methods: + - get_table_key + - get_obj_key + + Query: + methods: + - get_table + - get_description + + Results: + methods: + - from_table + - from_table_view + - is_valid + - get_query + - get_object_type + - get_type + - size + - index_of + - index_of_obj + - get_obj + - get_any + - sort_by_names + - snapshot + - max + - min + - average + - sum + - clear + - add_notification_callback + + Realm: + methods: + - get_shared_realm + - get_synchronized_realm + - get_schema_version + - config + - schema + - schema_version + - is_in_transaction + - is_in_migration + - is_closed + - sync_session + - get_latest_subscription_set + - begin_transaction + - commit_transaction + - cancel_transaction + - update_schema + - compact + - convert + - verify_open + - close + # JS-specific + - DOLLAR_addr + - DOLLAR_resetSharedPtr + + RealmCoordinator: + methods: + - clear_all_caches + + ObjectNotifier: + methods: + - add_callback + + NotificationToken: + methods: + - for_object + - unregister + + Collection: + methods: + - get_object_schema + - size + - is_valid + - get_any + - as_results + + List: + methods: + - make + - move + - remove + - remove_all + - swap + - delete_all + - insert_any + - insert_embedded + - set_any + + Set: + methods: + - make + - insert_any + - remove_any + - remove_all + - delete_all + + Dictionary: + methods: + - make + - get_keys + - get_values + - contains + - add_key_based_notification_callback + - insert_any + - insert_embedded + - try_get_any + - remove_all + - try_erase + + GoogleAuthCode: + methods: + - make + + GoogleIdToken: + methods: + - make + + AppCredentials: + methods: + - facebook + - anonymous + - apple + - google_auth + - google_id + - custom + - username_password + - function + - user_api_key + + SyncUser: + methods: + - all_sessions + - is_logged_in + - identity + - provider_type + - access_token + - refresh_token + - device_id + - user_profile + - identities + - custom_data + - sync_manager + - state + - session_for_on_disk_path + - subscribe + - unsubscribe + + UserProfile: + methods: + - data + + App: + methods: + - config + - current_user + - all_users + - sync_manager + - get_uncached_app + - clear_cached_apps + - log_in_with_credentials + - log_out_user + - refresh_custom_data + - link_user + - remove_user + - delete_user + - usernamePasswordProviderClient + - userAPIKeyProviderClient + - push_notification_client + - subscribe + - unsubscribe + - call_function + - make_streaming_request + + # WatchStream: + # methods: + # - make + # - state + # - error + # - next_event + + PushClient: + methods: + - register_device + - deregister_device + + UsernamePasswordProviderClient: + methods: + - register_email + - retry_custom_confirmation + - confirm_user + - resend_confirmation_email + - reset_password + - send_reset_password_email + - call_reset_password_function + + UserAPIKeyProviderClient: + methods: + - create_api_key + - fetch_api_key + - fetch_api_keys + - delete_api_key + - enable_api_key + - disable_api_key + + SyncManager: + methods: + - has_existing_sessions + - immediately_run_file_actions + - set_session_multiplexing + - set_log_level + - set_logger_factory + - set_user_agent + - reconnect + - path_for_realm + + AsyncOpenTask: + methods: + - start + - cancel + - register_download_progress_notifier + # JS-specific + - DOLLAR_resetSharedPtr + + SyncSession: + methods: + - state + - connection_state + - user + - config + - full_realm_url + - wait_for_upload_completion + - wait_for_download_completion + - register_progress_notifier + - unregister_progress_notifier + - register_connection_change_callback + - unregister_connection_change_callback + - revive_if_needed + - force_close + # JS-specific + - DOLLAR_resetSharedPtr + + SyncSubscriptionSet: + methods: + - version + - state + - error_str + - size + - make_mutable_copy + - get_state_change_notification + - find_by_name + - find_by_query + - refresh + + MutableSyncSubscriptionSet: + methods: + - clear + - insert_or_assign_by_name + - insert_or_assign_by_query + - erase_by_name + - erase_by_query + - commit diff --git a/packages/realm/bindgen/src/templates/wasm.ts b/packages/realm/bindgen/src/templates/wasm.ts new file mode 100644 index 0000000000..1f1f41504f --- /dev/null +++ b/packages/realm/bindgen/src/templates/wasm.ts @@ -0,0 +1,935 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2023 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// +import { strict as assert } from "assert"; + +import { TemplateContext } from "@realm/bindgen/context"; +import { CppVar, CppFunc, CppFuncProps, CppMethod, CppClass, CppDecls } from "@realm/bindgen/cpp"; +import { + BoundSpec, + Class, + InstanceMethod, + StaticMethod, + Property, + Type, + Primitive, + Pointer, + Template, +} from "@realm/bindgen/bound-model"; + +import { doJsPasses } from "../js-passes"; +import { clangFormat } from "@realm/bindgen/formatter"; + +const emscripten_call_args = new CppVar("const emscripten::val", "args"); + +function tryWrap(body: string) { + return `try { + ${body} + } catch (const std::exception& ex) { + toEmscriptenException(ex).throw_(); + } + `; +} + +class CppEmscriptenFunc extends CppFunc { + constructor( + private addon: BrowserAddon, + name: string, + numberOfArgs: number, + props?: CppFuncProps, + ) { + const vars = new Array(numberOfArgs); + for (let i = 0; i < numberOfArgs; i++) { + vars[i] = new CppVar("const emscripten::val", `arg${i}`); + } + super(name, "emscripten::val", vars, props); + } + + definition() { + return super.definition(` + const auto callBlock = ${this.addon.get()}->startCall(); + ${tryWrap(this.body)} + `); + } +} + +function pushRet(arr: T[], elem: U) { + arr.push(elem); + return elem; +} + +class BrowserAddon extends CppClass { + exports: Record = {}; + classes: string[] = []; + injectables = ["Float", "UUID", "ObjectId", "Decimal128", "EJSON_parse", "EJSON_stringify"]; + + constructor() { + super("RealmAddon"); + this.members.push(new CppVar("std::unique_ptr", "self", { static: true })); + this.members.push(new CppVar("std::deque", "m_string_bufs")); + this.addMethod( + new CppMethod("wrapString", "const std::string&", [new CppVar("std::string", "str")], { + attributes: "inline", + body: `return m_string_bufs.emplace_back(std::move(str));`, + }), + ); + this.addMethod( + new CppMethod("startCall", "auto", [], { + attributes: "inline", + body: `return ContainerResizer(m_string_bufs);`, + }), + ); + } + + generateMembers() { + this.injectables.forEach((t) => this.members.push(new CppVar("emscripten::val", BrowserAddon.memberNameFor(t)))); + this.classes.forEach((t) => + this.members.push(new CppVar("emscripten::val", BrowserAddon.memberNameForExtractor(t))), + ); + this.addMethod( + new CppMethod("injectInjectables", "void", [emscripten_call_args], { + body: ` + ${this.injectables.map((t) => `${BrowserAddon.memberNameFor(t)} = args["${t}"];`).join("\n")} + ${this.classes + .map( + (cls) => + `${BrowserAddon.memberNameForExtractor(cls)} = + ${BrowserAddon.memberNameFor(cls)}["_extract"];`, + ) + .join("\n")} + `, + }), + ); + } + + addFunc(name: string, numberOfArgs: number, props?: CppFuncProps) { + return new CppEmscriptenFunc(this, name, numberOfArgs, props); + } + + addClass(cls: Class) { + this.injectables.push(cls.jsName); + this.classes.push(cls.jsName); + } + + static memberNameForExtractor(cls: string | { jsName: string }) { + if (typeof cls != "string") cls = cls.jsName; + return `m_cls_${cls}_extractor`; + } + static memberNameFor(cls: string | { jsName: string }) { + if (typeof cls != "string") cls = cls.jsName; + return `m_cls_${cls}_ctor`; + } + + accessCtor(cls: string | { jsName: string }) { + return `${this.get()}->${BrowserAddon.memberNameFor(cls)}`; + } + + accessExtractor(cls: string | { jsName: string }) { + return `${this.get()}->${BrowserAddon.memberNameForExtractor(cls)}`; + } + + get() { + return `${this.name}::self`; + } +} + +function convertPrimToEmscripten(addon: BrowserAddon, type: string, expr: string): string { + switch (type) { + case "void": + return `((void)(${expr}), emscripten::val::undefined())`; + + case "bool": + return `emscripten::val(bool(${expr}))`; + + case "float": + return `${addon.accessCtor("Float")}.new_(${convertPrimToEmscripten(addon, "double", expr)})`; + + case "double": + case "int32_t": + return `emscripten::val(${expr})`; + + case "count_t": + return `emscripten::val(std::make_signed_t(${expr}))`; + + case "int64_t": + case "uint64_t": + return `emscripten::val(${expr})`; + + case "StringData": + case "std::string_view": + case "std::string": + return `([&] (auto&& sd) { + if(sd.size() == 0) { + return emscripten::val(""); + } else { + return emscripten::val(std::string(sd.data(), sd.size())); + } + }(${expr}))`; + + case "EncryptionKey": //TODO throw Error("EncryptionKey is not supported. Encryption is not supported in WASM"); + return "emscripten::val()"; + case "OwnedBinaryData": + case "BinaryData": + // construct a typed view around the C++ data. the underlying array buffer is the Emscripten heap + // call slice to create a copy and return the new underlying buffer + return `([&] (const auto& bd) -> emscripten::val { + emscripten::val typed_array(emscripten::typed_memory_view(bd.size(), bd.data())); + return typed_array.call("slice")["buffer"]; + }(${expr}))`; + + case "Mixed": + return `EMVAL_FROM_Mixed(${expr})`; + case "QueryArg": + // We _could_ support this, but no reason to. + throw Error("QueryArg should only be used for conversion to C++"); + + case "ObjectId": + case "UUID": + case "Decimal128": + return `${addon.accessCtor(type)}.new_(${convertPrimToEmscripten(addon, "std::string", `${expr}.to_string()`)})`; + + case "EJson": + case "EJsonObj": + case "EJsonArray": + return `${addon.accessCtor("EJSON_parse")}(${convertPrimToEmscripten(addon, "std::string", expr)})`; + + case "bson::BsonArray": + case "bson::BsonDocument": + return convertPrimToEmscripten(addon, "EJsonObj", `bson::Bson(${expr}).to_string()`); + + case "AppError": + // This matches old JS SDK. The C++ type will be changing as part of the unify error handleing project. + return `([&] (const app::AppError& err) { + auto jsErr = emscripten::val::global("Error")(emscripten::val(err.what())); + jsErr.set("code", double(err.code())); + return jsErr; + }(${expr}))`; + case "std::exception_ptr": + return `toEmscriptenException(${expr})`; + case "std::error_code": + return `toEmscriptenErrorCode(${expr})`; + case "Status": + return `([&] (const Status& status) { + if (status.is_ok()) { + return emscripten::val::undefined(); + } else { + return emscripten::val(status.reason().c_str()); + } + }(${expr}))`; + } + assert.fail(`unexpected primitive type '${type}'`); +} +function convertPrimFromEmscripten(addon: BrowserAddon, type: string, expr: string): string { + switch (type) { + case "void": + return `((void)(${expr}))`; + + case "bool": + return `(${expr}).as()`; + + case "double": + return `(${expr}).as()`; + case "float": + return `(${expr}["value"]).as()`; + + case "int32_t": + return `(${expr}).as()`; + + case "count_t": + // NOTE: using Int64 here is important to correctly handle -1.0 aka npos. + // FIXME should use int64_t + return `size_t((${expr}).as())`; + + case "int64_t": + return `${expr}.as()`; + case "uint64_t": + return `${expr}.as()`; + + case "std::string": + return `${addon.get()}->wrapString((${expr}).as())`; + + case "StringData": + case "std::string_view": + return `${convertPrimFromEmscripten(addon, "std::string", expr)}`; + + case "BinaryData": + return `BinaryData(${addon.get()}->wrapString(toBinaryData(${expr})))`; + case "OwnedBinaryData": + return `toOwnedBinaryData(${expr})`; + + case "EncryptionKey": //TODO assert.fail("Encryption is not supported in WASM."); + return "std::vector()"; + case "Mixed": + return `EMVAL_TO_Mixed(${expr})`; + case "QueryArg": { + const mixed = new Primitive("Mixed"); + return ` + ([&] (const emscripten::val& v) -> ${new Primitive(type).toCpp()} { + if (v.isArray()) { + return ${convertFromEmscripten(addon, new Template("std::vector", [mixed]), "v")}; + } else { + return ${convertFromEmscripten(addon, mixed, "v")}; + } + })(${expr})`; + } + + case "UUID": + case "Decimal128": + case "ObjectId": + return `${type}((${expr}.call("toString").c_str()))`; + + case "EJson": + case "EJsonObj": + case "EJsonArray": + return convertPrimFromEmscripten(addon, "std::string", `${addon.accessCtor("EJSON_stringify")}(${expr})`); + + case "bson::BsonArray": + case "bson::BsonDocument": + return `${type}(bson::parse(${convertPrimFromEmscripten(addon, "EJsonObj", expr)}))`; + + case "AppError": + assert.fail("Cannot convert AppError to C++, only from C++."); + } + assert.fail(`unexpected primitive type '${type}'`); +} + +function convertToEmscripten(addon: BrowserAddon, type: Type, expr: string): string { + const c = convertToEmscripten.bind(null, addon); // shortcut for recursion + switch (type.kind) { + case "Primitive": + return convertPrimToEmscripten(addon, type.name, expr); + case "Pointer": + return `[&] (const auto& ptr){ + if constexpr(requires{ bool(ptr); }) { // support claiming that always-valid iterators are pointers. + REALM_ASSERT(bool(ptr) && "Must mark nullable pointers with Nullable<> in spec"); + } + return ${c(type.type, "*ptr")}; + } (${expr})`; + + case "Opaque": + return `emscripten::val(reinterpret_cast(&(${expr})))`; + + case "Const": + case "Ref": + case "RRef": // Note: not explicitly taking advantage of moveability yet. TODO? + return c(type.type, expr); + + case "KeyType": + return c(type.type, `(${expr}).value`); + + case "Template": + // Most templates only take a single argument so do this here. + const inner = type.args[0]; + switch (type.name) { + case "std::shared_ptr": + if (inner.kind == "Class" && inner.sharedPtrWrapped) return `EMVAL_FROM_SHARED_${inner.name}(${expr})`; + return c(new Pointer(inner), expr); + case "Nullable": { + return `[&] (auto&& val) { return !val ? emscripten::val::null() : ${c(inner, "FWD(val)")}; }(${expr})`; + } + case "util::Optional": + return `[&] (auto&& opt) { return !opt ? emscripten::val::undefined() : ${c(inner, "*FWD(opt)")}; }(${expr})`; + case "std::vector": + return `[&] (auto&& vec) { + auto out = emscripten::val::array(); + for (auto&& e : vec) { + out.call("push", ${c(inner, "e")}); + } + return out; + }(${expr})`; + case "std::pair": + case "std::tuple": + return ` + [&] (auto&& tup) { + auto out = emscripten::val::array(); // of size ${type.args.length} + ${type.args + .map((arg, i) => `out.call("push", ${c(arg, `std::get<${i}>(FWD(tup))`)});`) + .join("\n")} + return out; + }(${expr})`; + case "std::map": + case "std::unordered_map": + // Note: currently assuming that key is natively supported by js object setter (string or number). + return ` + [&] (auto&& map) { + auto out = emscripten::val::object(); + for (auto&& [k, v] : map) { + out.set(k, ${c(type.args[1], "v")}); + } + return out; + }(${expr})`; + case "AsyncCallback": + case "util::UniqueFunction": + case "std::function": + assert.equal(inner.kind, "Func"); + return c(inner, `FWD(${expr})`); + case "AsyncResult": + assert.fail("Should never see AsyncResult here"); + } + return assert.fail(`unknown template ${type.name}`); + + case "Class": + assert(!type.sharedPtrWrapped, `should not directly convert from ${type.name} without shared_ptr wrapper`); + return `EMVAL_FROM_CLASS_${type.name}(${expr})`; + + case "Struct": + return `${type.toEmscripten().name}(${expr})`; + + case "Func": + // TODO: see if we want to try to propagate a function name in rather than always making them anonymous. + return ` + [&] (auto&& cb) -> emscripten::val { + if constexpr(std::is_constructible_v) { + REALM_ASSERT(bool(cb) && "Must mark nullable callbacks with Nullable<> in spec"); + } + const auto callBlock = ${addon.get()}->startCall(); + ${tryWrap(` + return ${c( + type.ret, + `cb(${type.args + .map((arg, i) => convertFromEmscripten(addon, arg.type, `arg${i}`)) + .join(", ")})`, + )}; + `)} + }(${expr})`; + + case "Enum": + return `emscripten::val(int(${expr}))`; + + default: + const _exhaustiveCheck: never = type; + return _exhaustiveCheck; + } +} +function convertFromEmscripten(addon: BrowserAddon, type: Type, expr: string): string { + const c = convertFromEmscripten.bind(null, addon); // shortcut for recursion + switch (type.kind) { + case "Primitive": + return convertPrimFromEmscripten(addon, type.name, expr); + case "Pointer": + return `&(${c(type.type, expr)})`; + case "Opaque": + return `(*(reinterpret_cast<${type.name}*>(${expr}.as())))`; + + case "KeyType": + return `${type.name}(${c(type.type, expr)})`; + + case "Const": + case "Ref": + return c(type.type, expr); + + case "RRef": { + // For now, copying. TODO Consider moving instead, although we may want a marker in JS code. + // Also, for now, only doing this if the child is a class, since A) that is where we need it, + // and B) other things may use lambdas which cause compile failures with our `auto(expr)` + // emulation until C++20. + const inner = c(type.type, expr); + return type.type.kind == "Class" ? `REALM_DECAY_COPY(${inner})` : inner; + } + + case "Template": + // Most templates only take a single argument so do this here. + const inner = type.args[0]; + + switch (type.name) { + case "std::shared_ptr": + if (inner.kind == "Class" && inner.sharedPtrWrapped) return `EMVAL_TO_SHARED_${inner.name}(${expr})`; + return `std::make_shared<${inner.toCpp()}>(${c(inner, expr)})`; + case "Nullable": + return `[&] (emscripten::val val) { return val.isNull() ? ${inner.toCpp()}() : ${c( + inner, + "val", + )}; }(${expr})`; + case "util::Optional": + return `[&] (emscripten::val val) { + return val.isUndefined() ? ${type.toCpp()}() : ${c(inner, "val")}; + }(${expr})`; + case "std::vector": + return `[&] (const emscripten::val vec) { + assert(vec.isArray()); + auto out = std::vector<${inner.toCpp()}>(); + + const uint32_t length = vec["length"].as(); + out.reserve(length); + for (uint32_t i = 0; i < length; i++) { + out.push_back(${c(inner, "vec[i]")}); + } + return out; + }(${expr})`; + case "std::tuple": + case "std::pair": + const suffix = type.name.split(":")[2]; + const nArgs = type.args.length; + return `[&] (const emscripten::val& arr) { + if (arr["length"].as() != ${nArgs}u) + emscripten::val("Need an array with exactly ${nArgs} elements").throw_(); + return std::make_${suffix}(${type.args.map((arg, i) => c(arg, `arr[${i}u]`))}); + }(${expr})`; + case "std::map": + case "std::unordered_map": + // For now, can only convert string-keyed maps to C++. + // We could also support numbers pretty easily. Anything else will be problematic. + // Consider list-of-pairs for keys that aren't strings or numbers. + assert.deepEqual(type.args[0], new Primitive("std::string")); + return `[&] (const emscripten::val obj) { + auto out = ${type.toCpp()}(); + auto entries = emscripten::val::global("Object")["entries"](obj); + const auto length = entries["length"].as(); + for (uint32_t i = 0; i < length; i++) { + out.insert({ + entries[i][0].as(), + ${c(type.args[1], "entries[i][1]")} + }); + } + return out; + }(${expr})`; + case "AsyncCallback": + case "std::function": + return `${type.toCpp()}(${c(inner, expr)})`; + } + return assert.fail(`unknown template ${type.name}`); + + case "Class": + if (type.sharedPtrWrapped) return `*EMVAL_TO_SHARED_${type.name}(${expr})`; + return `EMVAL_TO_CLASS_${type.name}(${expr})`; + + case "Struct": + return `${type.fromEmscripten().name}(${expr})`; + + case "Func": + const lambda = ` + [ + _cb = FWD(${expr}) + ] + (${type.args + .map(({ name, type }) => `${type.toCpp()} ${type.isTemplate("IgnoreArgument") ? "" : name}`) + .join(", ")} + ) -> ${type.ret.toCpp()} + { + return ${c( + type.ret, + `_cb( + ${type + .argsSkippingIgnored() + .map(({ name, type }) => convertToEmscripten(addon, type, `FWD(${name})`)) + .join(", ")})`, + )}; + }`; + return lambda; + // if (!type.isOffThread) return lambda; + + // For now assuming that all void-returning functions are "notifications" and don't need to block until done. + // Non-void returning functions *must* block so they have something to return. + // const shouldBlock = !type.ret.isVoid(); + // return shouldBlock ? `schedulerWrapBlockingFunction(${lambda})` : `util::EventLoopDispatcher(${lambda})`; + + case "Enum": + return `${type.cppName}((${expr}).as())`; + + default: + const _exhaustiveCheck: never = type; + return _exhaustiveCheck; + } +} + +declare module "@realm/bindgen/bound-model" { + interface Struct { + toEmscripten: () => CppFunc; + fromEmscripten: () => CppFunc; + } + interface Method { + readonly emscriptenDescriptorType: string; + } +} + +function constCast(obj: T) { + return obj as { -readonly [k in keyof T]: T[k] }; +} + +constCast(InstanceMethod.prototype).emscriptenDescriptorType = "InstanceMethod"; +constCast(StaticMethod.prototype).emscriptenDescriptorType = "StaticMethod"; +constCast(Property.prototype).emscriptenDescriptorType = "InstanceAccessor"; + +class BrowserCppDecls extends CppDecls { + addon = pushRet(this.classes, new BrowserAddon()); + boundSpec: BoundSpec; + methodFunctions: string[] = []; + + constructor(spec: BoundSpec) { + super(); + this.boundSpec = spec; + + for (const enm of spec.enums) { + this.static_asserts.push(`sizeof(${enm.cppName}) <= sizeof(int32_t), "we only support enums up to 32 bits"`); + for (const { name, value } of enm.enumerators) { + this.static_asserts.push(`${enm.cppName}(int(${value})) == ${enm.cppName}::${name}`); + } + } + + for (const struct of spec.records) { + // Lazily create the to/from conversions only as needed. This is important because some structs + // can only be converted in one direction. + let toEmscripten: CppFunc | undefined; + let fromEmscripten: CppFunc | undefined; + + struct.toEmscripten = () => { + if (!toEmscripten) { + toEmscripten = new CppFunc( + `STRUCT_TO_EMVAL_${struct.name}`, + "emscripten::val", + [new CppVar(`const ${struct.cppName}&`, "in")], + { + body: ` + auto out = emscripten::val::object(); + ${struct.fields + .filter((field) => !field.type.isFunction() && field.isOptedInTo) + .map( + (field) => + `out.set("${field.jsName}", ${convertToEmscripten( + this.addon, + field.type, + `in.${field.cppName}`, + )});`, + ) + .join("\n")} + return out; + `, + }, + ); + this.free_funcs.push(toEmscripten); + } + return toEmscripten; + }; + + struct.fromEmscripten = () => { + if (!fromEmscripten) { + for (const field of struct.fields) { + if (field.cppName && field.cppName.endsWith(")")) { + // If this fires, we should consider a way to mark these fields as only being for one-way conversion. + throw new Error( + `Attempting JS->C++ conversion of ${struct.name}::${field.name} which looks like it may be a method`, + ); + } + } + fromEmscripten = new CppFunc( + `STRUCT_FROM_EMVAL_${struct.name}`, + struct.cppName, + [new CppVar("emscripten::val", "val")], + { + body: ` + auto out = ${struct.cppName}(); + ${struct.fields + .filter((field) => field.isOptedInTo) + .map( + (field) => `{ + auto field = val["${field.jsName}"]; + if (!field.isUndefined()) { + // Make functions on structs behave like bound methods. + if (field.instanceof(emscripten::val::global("Function"))) + field = field.call("bind", val); + out.${field.cppName} = ${convertFromEmscripten(this.addon, field.type, "field")}; + } else if constexpr (${field.required ? "true" : "false"}) { + emscripten::val("${struct.jsName}::${field.jsName} is required").throw_(); + } + }`, + ) + .join("\n")} + return out; + `, + }, + ); + this.free_funcs.push(fromEmscripten); + } + return fromEmscripten; + }; + } + + for (const cls of spec.classes) { + assert( + !cls.sharedPtrWrapped || (!cls.base && cls.subclasses.length == 0), + `We don't support mixing sharedPtrWrapped and class hierarchies. ${cls.name} requires this.`, + ); + + this.addon.addClass(cls); + + const baseType = cls.sharedPtrWrapped ? `std::shared_ptr<${cls.cppName}>` : cls.rootBase().cppName; + const derivedType = cls.sharedPtrWrapped ? `std::shared_ptr<${cls.cppName}>` : cls.cppName; + const ptr = (expr: string) => `reinterpret_cast<${baseType}*>(${expr}.as())`; + const casted = (expr: string) => (cls.base ? `static_cast<${derivedType}*>(${ptr(expr)})` : ptr(expr)); + const self = `(${cls.needsDeref ? "**" : "*"}${casted("arg0")})`; + + const selfCheck = (isStatic: boolean) => { + return isStatic ? "" : ""; + }; + + for (const method of cls.methods) { + if (!method.isOptedInTo) continue; + + const argOffset = method.isStatic ? 0 : 1; // `this` takes arg 0 if not static + const args = method.sig.args.map((a, i) => convertFromEmscripten(this.addon, a.type, `arg${i + argOffset}`)); + // console.log(`GENERATING method = ${method.id} length = ${method.sig.args.length + argOffset}\n`); + this.free_funcs.push( + this.addon.addFunc(method.id, method.sig.args.length + argOffset, { + body: ` + ${selfCheck(method.isStatic)} + return ${convertToEmscripten(this.addon, method.sig.ret, method.call({ self }, ...args))}; + `, + }), + ); + this.methodFunctions.push(method.id); + } + + if (cls.iterable) { + this.free_funcs.push( + this.addon.addFunc(cls.iteratorMethodId(), 1, { + body: ` + emscripten::val jsIt = emscripten::val::object(); + auto& self = ${self}; + + std::function incrementIterators = [begin = std::make_move_iterator(self.begin()), end = std::make_move_iterator(self.end())]() mutable { + emscripten::val iteratorResult = emscripten::val::object(); + if (begin == end) { + iteratorResult.set("done", true); + } + else { + iteratorResult.set("value", ${convertToEmscripten(this.addon, cls.iterable, "*begin")}); + ++begin; + } + return iteratorResult; + }; + + // Allocate memory for the lambda on the heap + auto* lambdaPtr = new decltype(incrementIterators)(incrementIterators); + // Get the address of the lambda + std::uintptr_t lambdaAddress = reinterpret_cast(lambdaPtr); + + int jsInternalIteratorCall = EM_ASM_INT({ + var myFunction = function() { + return Module["_internal_iterator"]($0); + }; + return Emval.toHandle(myFunction); + }, lambdaAddress); + jsIt.set("next", emscripten::val::take_ownership((emscripten::EM_VAL)(jsInternalIteratorCall))); + return jsIt; + `, + }), + ); + this.methodFunctions.push(cls.iteratorMethodId()); + } + + const refType = cls.sharedPtrWrapped ? `const ${derivedType}&` : `${derivedType}&`; + const kind = cls.sharedPtrWrapped ? "SHARED" : "CLASS"; + this.free_funcs.push( + new CppFunc(`EMVAL_TO_${kind}_${cls.name}`, refType, [new CppVar("emscripten::val", "val")], { + attributes: "[[maybe_unused]]", + body: ` + emscripten::val external = ${this.addon.accessExtractor(cls)}(val); + const auto ptr = ${casted(`external`)}; + ${ + cls.sharedPtrWrapped + ? `if (!*ptr) emscripten::val("Attempting to use an instanace of ${cls.name} holding a null shared_ptr. Did you call $resetSharedPtr on it already?").throw_();` + : "" + } + return *ptr; + `, + }), + ); + + const nullCheck = cls.sharedPtrWrapped + ? 'REALM_ASSERT(bool(val) && "Must mark nullable pointers with Nullable<> in spec");' + : ""; + + if (!cls.abstract) { + this.free_funcs.push( + new CppFunc(`EMVAL_FROM_${kind}_${cls.name}`, "emscripten::val", [new CppVar(derivedType, "val")], { + attributes: "[[maybe_unused]]", + body: ` + ${nullCheck} + return ${this.addon.accessCtor( + cls, + )}.new_(emscripten::val(reinterpret_cast(new auto(std::move(val))))); + `, + }), + new CppFunc(`${cls.name}_deleter`, "void", [new CppVar("emscripten::val", "pointer")], { + body: + kind === "SHARED" + ? `delete reinterpret_cast*>(pointer.as());` + : `delete reinterpret_cast<${cls.cppName}*>(pointer.as());`, + }), + ); + this.methodFunctions.push(`${cls.name}_deleter`); + } + } + + // Adding internal iterator function + this.free_funcs.push( + new CppFunc("_internal_iterator", "emscripten::val", [new CppVar("std::uintptr_t", "lambdaAddress")], { + body: ` + std::function* func = reinterpret_cast*>(lambdaAddress); + emscripten::val val = (*func)(); + + // Deallocate the lambda from the heap if it's the last element + if (!val["done"].isUndefined()) { + delete func; + } + return val; + `, + }), + ); + + this.free_funcs.push( + new CppFunc("EMVAL_FROM_Mixed", "emscripten::val", [new CppVar("Mixed", "val")], { + body: ` + if (val.is_null()) + return emscripten::val::null(); + switch (val.get_type()) { + ${spec.mixedInfo.getters + .map( + (g) => ` + case DataType::Type::${g.dataType}: + return ${convertToEmscripten(this.addon, g.type, `val.${g.getter}()`)}; + `, + ) + .join("\n")} + // The remaining cases are never stored in a Mixed. + ${spec.mixedInfo.unusedDataTypes.map((t) => `case DataType::Type::${t}: break;`).join("\n")} + } + REALM_UNREACHABLE(); + `, + }), + new CppFunc("EMVAL_TO_Mixed", "Mixed", [new CppVar("emscripten::val", "val")], { + body: ` + auto type = val.typeOf().as(); + if (type == "string") { + return ${convertFromEmscripten(this.addon, spec.types["StringData"], "val")}; + + } else if (type == "boolean") { + return ${convertFromEmscripten(this.addon, spec.types["bool"], "val")}; + + } else if (type == "number") { + return val.as(); + + } else if (type == "bigint") { + return val.as(); + + } else if (type == "object") { + if(val.isNull()) { + return Mixed(); + } + if (val.instanceof(emscripten::val::global("ArrayBuffer"))) { + return ${convertFromEmscripten(this.addon, spec.types["BinaryData"], "val")}; + } + else if (val.instanceof(emscripten::val::global("DataView"))) { + return ${convertFromEmscripten(this.addon, spec.types["BinaryData"], 'val["buffer"]')}; + } + ${ + // This list should be sorted in in roughly the expected frequency since earlier entries will be faster. + [ + ["Obj", "Obj"], + ["Timestamp", "Timestamp"], + ["float", "Float"], + ["ObjLink", "ObjLink"], + ["ObjectId", "ObjectId"], + ["Decimal128", "Decimal128"], + ["UUID", "UUID"], + ] + .map( + ([typeName, jsName]) => + `else if (val.instanceof(${this.addon.accessCtor(jsName)})) { + return ${convertFromEmscripten(this.addon, spec.types[typeName], "val")}; + }`, + ) + .join(" ") + } + + const auto ctorName = + val["constructor"]["name"].as(); + + emscripten::val::global("Error")(util::format("Unable to convert an object with ctor '%1' to a Mixed", ctorName)).throw_(); + } else { + // NOTE: must not treat undefined as null here, because that makes Optional ambiguous. + emscripten::val::global("Error")(util::format("Can't convert %1 to Mixed", type)).throw_(); + } + + REALM_UNREACHABLE(); + `, + }), + ); + + this.addon.generateMembers(); + } + + outputDefsTo(out: (...parts: string[]) => void) { + super.outputDefsTo(out); + out(` + void browser_init() + { + if (!RealmAddon::self) { + RealmAddon::self = std::make_unique(); + } + } + void injectExternalTypes(emscripten::val val) + { + RealmAddon::self->injectInjectables(val); + } + `); + + // export method functions via embind + out(`\nEMSCRIPTEN_BINDINGS(realm_c_api) {`); + out("\nusing emscripten::function;"); + this.methodFunctions.map((fun: string) => { + out(`\nfunction("${fun}", &${fun});`); + }); + + // this.boundSpec.classes.forEach((c) => { + // out(`\nfunction("${c.jsName}_deleter", &${c.jsName}_deleter);`); + // }); + + out(`\nfunction("_internal_iterator", &_internal_iterator);`); + out(`\nfunction("browserInit", &browser_init);`); + out(`\nfunction("injectInjectables", &injectExternalTypes);`); + + out("\n}"); + } +} + +export function generate({ rawSpec, spec, file: makeFile }: TemplateContext): void { + const out = makeFile("browser_init.cpp", clangFormat); + + // HEADER + out(`// This file is generated: Update the spec instead of editing this file directly`); + + for (const header of rawSpec.headers) { + out(`#include <${header}>`); + } + + out(` + #include + #include + #include + + namespace realm::js::browser { + namespace { + `); + + new BrowserCppDecls(doJsPasses(spec)).outputDefsTo(out); + + out(` + } // namespace + } // namespace realm::js::browser + `); +} diff --git a/packages/realm/binding/wasm/CMakeLists.txt b/packages/realm/binding/wasm/CMakeLists.txt new file mode 100644 index 0000000000..c905f36df3 --- /dev/null +++ b/packages/realm/binding/wasm/CMakeLists.txt @@ -0,0 +1,64 @@ +# The initial part of this file is basically just copied from the root package CML files. +# TODO: look into how to commonize some of this. + +include(CheckCXXCompilerFlag) + +set(SDK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../) +set(BINDGEN_DIR ${SDK_DIR}/bindgen) +set(BINDING_DIR ${SDK_DIR}/binding) + +cmake_minimum_required(VERSION 3.17) +project(RealmJS) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# set(CMAKE_CXX_VISIBILITY_PRESET hidden) +# set(CMAKE_POSITION_INDEPENDENT_CODE ON) +# set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +set(REALM_BUILD_LIB_ONLY ON) +set(REALM_ENABLE_SYNC ON) + +add_subdirectory(${BINDGEN_DIR}/vendor/realm-core realm-core EXCLUDE_FROM_ALL) + +add_library(realm-js OBJECT) +target_link_libraries(realm-js Realm::ObjectStore) +target_compile_options(realm-js PRIVATE -Wall -Wextra) +target_include_directories(realm-js PRIVATE "${BINDGEN_DIR}/src") +target_include_directories(realm-js PRIVATE "${BINDING_DIR}") + +file(GLOB_RECURSE SDK_TS_FILES + LIST_DIRECTORIES false + CONFIGURE_DEPENDS + ${SDK_DIR}/bindgen/src/*.ts +) + +# Each template command should include its file as an explicit dependency. +# This avoids needing to re-run all generators for changes that could only affect one of them. +list(FILTER SDK_TS_FILES EXCLUDE REGEX "templates/[^/]*\.ts$") + +set(JS_SPEC_FILE ${SDK_DIR}/bindgen/js_spec.yml) +set(JS_OPT_IN_FILE ${SDK_DIR}/bindgen/browser_opt_in_spec.yml) +set(WASM_OUTPUT_DIR ${SDK_DIR}/prebuilds/wasm) + +bindgen( + TEMPLATE ${SDK_DIR}/bindgen/src/templates/wasm.ts + OUTPUTS wasm_init.cpp + OUTDIR ${CMAKE_CURRENT_BINARY_DIR} + SPECS ${JS_SPEC_FILE} + OPTIN ${JS_OPT_IN_FILE} + SOURCES ${SDK_TS_FILES} +) + +target_sources(realm-js PRIVATE wasm_init.cpp ${CMAKE_JS_SRC} ${BINDING_DIR}/wasm/platform.cpp) + +add_executable(realm-js-wasm) +target_link_options(realm-js-wasm PRIVATE -d -sALLOW_MEMORY_GROWTH=1 -sLLD_REPORT_UNDEFINED -sFETCH=1 -lembind -fwasm-exceptions -sEXPORT_ES6=1 -sWASM_BIGINT=1 -sENVIRONMENT=web -sSTACK_SIZE=131072 --pre-js=../web_polyfill.js) +target_link_libraries(realm-js-wasm realm-js) + +set_target_properties(realm-js-wasm PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${WASM_OUTPUT_DIR} +) +SET(CMAKE_EXECUTABLE_SUFFIX ".mjs") diff --git a/packages/realm/binding/wasm/platform.cpp b/packages/realm/binding/wasm/platform.cpp new file mode 100644 index 0000000000..c772c10d34 --- /dev/null +++ b/packages/realm/binding/wasm/platform.cpp @@ -0,0 +1,62 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +#include +#include +#include + +#include "../platform.hpp" + +namespace realm { + +std::string JsPlatformHelpers::default_realm_file_directory() +{ + return std::string(""); +} + +void JsPlatformHelpers::ensure_directory_exists_for_file(const std::string&) +{ + throw std::runtime_error("Realm for browser does not support this method."); +} + +void JsPlatformHelpers::copy_bundled_realm_files() +{ + throw std::runtime_error("Realm for browser does not support this method."); +} + +void JsPlatformHelpers::remove_realm_files_from_directory(const std::string&) +{ + throw std::runtime_error("Realm for browser does not support this method."); +} + +void JsPlatformHelpers::remove_directory(const std::string&) +{ + throw std::runtime_error("Realm for browser does not support this method."); +} + +void JsPlatformHelpers::remove_file(const std::string&) +{ + throw std::runtime_error("Realm for browser does not support this method."); +} + +std::string JsPlatformHelpers::get_cpu_arch() +{ + return "N/A WASM"; +} + +} // namespace realm \ No newline at end of file diff --git a/packages/realm/package.json b/packages/realm/package.json index 8de4bd4a93..4814a5f6c1 100644 --- a/packages/realm/package.json +++ b/packages/realm/package.json @@ -85,6 +85,7 @@ "prebuild-apple:simulator": "wireit", "prebuild-android": "wireit", "prebuild-node": "wireit", + "prebuild-wasm": "wireit", "build:ts": "wireit", "build:node": "wireit", "bindgen:jsi": "wireit", @@ -137,6 +138,25 @@ "prebuilds/android" ] }, + "prebuild-node": { + "command": "cross-env-shell prebuild --runtime napi --arch $PREBUILD_ARCH -- --directory binding/node", + "env": { + "PREBUILD_ARCH": { + "external": true, + "default": "undefined" + } + } + }, + "prebuild-wasm": { + "command": "tsx ./src/scripts/build/cli.ts build-wasm", + "files": [ + "bindgen/vendor/realm-core/src", + "src/scripts" + ], + "output": [ + "prebuilds/wasm" + ] + }, "build:ts": { "command": "tsc --build", "dependencies": [ @@ -207,15 +227,6 @@ } ] }, - "prebuild-node": { - "command": "cross-env-shell prebuild --runtime napi --arch $PREBUILD_ARCH -- --directory binding/node", - "env": { - "PREBUILD_ARCH": { - "external": true, - "default": "undefined" - } - } - }, "check-types": { "command": "tsc --project tsconfig.public-types-check.json", "dependencies": [ diff --git a/packages/realm/src/scripts/build/cli.ts b/packages/realm/src/scripts/build/cli.ts index 4812abdc0c..b6ac21316c 100644 --- a/packages/realm/src/scripts/build/cli.ts +++ b/packages/realm/src/scripts/build/cli.ts @@ -31,7 +31,8 @@ import { Option, program } from "@commander-js/extra-typings"; import * as apple from "./apple"; import * as android from "./android"; import * as xcode from "./xcode"; -import { REALM_CORE_PATH, SUPPORTED_CONFIGURATIONS } from "./common"; +import * as wasm from "./wasm"; +import { PACKAGE_PATH, REALM_CORE_PATH, SUPPORTED_CONFIGURATIONS, ensureDirectory } from "./common"; export { program }; @@ -57,9 +58,9 @@ function actionWrapper(action: (...args: Args) => Promis } catch (err) { process.exitCode = 1; if (err instanceof Error) { - console.error(`ERROR: ${err.stack}`); + console.error(err.stack); if (err.cause instanceof Error) { - console.error(`CAUSE: ${err.cause.message}`); + console.error(`(cause): ${err.cause.stack}`); } } else { throw err; @@ -176,6 +177,26 @@ program }), ); +program + .command("build-wasm") + .description("Build native code for WASM") + .option("--clean", "Delete any build directory first", false) + .addOption(configurationOption) + .action( + actionWrapper(({ configuration, clean }) => { + assert(fs.existsSync(REALM_CORE_PATH), `Expected Realm Core at '${REALM_CORE_PATH}'`); + const { CMAKE_PATH: cmakePath = execSync("which cmake", { encoding: "utf8" }).trim() } = env; + + wasm.check(); + const sourcePath = path.relative(PACKAGE_PATH, "binding/wasm"); + const buildPath = path.relative(PACKAGE_PATH, "binding/wasm/build"); + ensureDirectory(buildPath, clean); + wasm.configure({ cmakePath, sourcePath, buildPath, configuration }); + wasm.build({ cmakePath, buildPath }); + + console.log("Great success! 🥳"); + }), + ); if (require.main === module) { program.parse(); } diff --git a/packages/realm/src/scripts/build/wasm.ts b/packages/realm/src/scripts/build/wasm.ts new file mode 100644 index 0000000000..5aef2be5cd --- /dev/null +++ b/packages/realm/src/scripts/build/wasm.ts @@ -0,0 +1,78 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import assert from "node:assert"; +import { execSync, spawnSync } from "node:child_process"; +import { Configuration, REALM_CORE_VERSION } from "./common"; + +export function check() { + try { + execSync("which emcc", { encoding: "utf8" }).trim(); + execSync("emcc --check"); + } catch (err) { + throw new Error( + "Failed to locate the Emscripten compiler frontend - did you follow the steps for building WASM in contrib/building.md?", + { cause: err }, + ); + } +} + +type ConfigureOptions = { + cmakePath: string; + sourcePath: string; + buildPath: string; + configuration: Configuration; +}; + +export function configure({ cmakePath, sourcePath, buildPath, configuration }: ConfigureOptions) { + const { status } = spawnSync( + "emcmake", + [ + cmakePath, + "-G", + "Ninja", + "-S", + sourcePath, + "-B", + buildPath, + "-D", + `CMAKE_BUILD_TYPE=${configuration}`, + "-D", + "CMAKE_MAKE_PROGRAM=ninja", + // "-D", + // "CMAKE_C_COMPILER_LAUNCHER=ccache", + // "-D", + // "CMAKE_CXX_COMPILER_LAUNCHER=ccache", + // Realm specific variables below + "-D", + `REALM_VERSION=${REALM_CORE_VERSION}`, + ], + { stdio: "inherit" }, + ); + assert.equal(status, 0, `Expected a clean exit (got status = ${status})`); +} + +type BuildOptions = { + cmakePath: string; + buildPath: string; +}; + +export function build({ cmakePath, buildPath }: BuildOptions) { + const { status } = spawnSync(cmakePath, ["--build", buildPath], { stdio: "inherit" }); + assert.equal(status, 0, `Expected a clean exit (got status = ${status})`); +}