diff --git a/.changeset/pink-feet-strive.md b/.changeset/pink-feet-strive.md new file mode 100644 index 00000000..fb05c5fe --- /dev/null +++ b/.changeset/pink-feet-strive.md @@ -0,0 +1,5 @@ +--- +"@godot-js/editor": minor +--- + +Node rpc and rpc_id/rpcId methods are now type safe thanks to additional codegen for RPCs. diff --git a/bridge/jsb_class_info.h b/bridge/jsb_class_info.h index f5b0b6a6..85c824ca 100644 --- a/bridge/jsb_class_info.h +++ b/bridge/jsb_class_info.h @@ -241,6 +241,7 @@ namespace jsb }; // Safe pointer of ScriptClassInfo + typedef internal::SArray ScriptClassInfoArray; typedef internal::SArray::Pointer ScriptClassInfoPtr; typedef internal::SArray::ConstPointer ScriptClassInfoConstPtr; diff --git a/bridge/jsb_editor_utility_funcs.cpp b/bridge/jsb_editor_utility_funcs.cpp index 9df14051..9b9b9b75 100644 --- a/bridge/jsb_editor_utility_funcs.cpp +++ b/bridge/jsb_editor_utility_funcs.cpp @@ -1,5 +1,7 @@ #include "jsb_editor_utility_funcs.h" #include "jsb_type_convert.h" +#include "jsb_environment.h" +#include "core/object/script_language.h" #if GODOT_4_6_OR_NEWER using ConstantHashMap = AHashMap; @@ -346,7 +348,7 @@ namespace jsb set_field(isolate, context, signal_obj, "method_", method_obj); } - v8::Local build_class_info(v8::Isolate* isolate, const v8::Local& context, const StringName& class_name) + v8::Local build_class_info(v8::Isolate* isolate, const v8::Local& context, const StringName& class_name, const HashSet* class_rpc_methods) { v8::Local class_info_obj = v8::Object::New(isolate); const HashMap::Iterator class_it = ClassDB::classes.find(class_name); @@ -420,6 +422,41 @@ namespace jsb } } + // class: rpc methods + { + JSB_HANDLE_SCOPE(isolate); + + v8::Local rpc_methods_obj = v8::Array::New(isolate); + set_field(isolate, context, class_info_obj, "rpc_methods", rpc_methods_obj); + + if (class_rpc_methods) + { + int index = 0; + + for (const KeyValue& pair : class_info.method_map) + { + MethodBind const * const method_bind = pair.value; + + if (method_bind->is_static()) + { + continue; + } + + const StringName exposed_method_name = internal::NamingUtil::get_member_name(pair.key); + + if (!class_rpc_methods->has(pair.key) && !class_rpc_methods->has(exposed_method_name)) + { + continue; + } + + JSB_HANDLE_SCOPE(isolate); + v8::Local method_info_obj = v8::Object::New(isolate); + build_method_info(isolate, context, method_bind, method_info_obj); + rpc_methods_obj->Set(context, index++, method_info_obj).Check(); + } + } + } + // class: gd virtual methods { JSB_HANDLE_SCOPE(isolate); @@ -936,15 +973,33 @@ namespace jsb v8::HandleScope handle_scope(isolate); v8::Local context = isolate->GetCurrentContext(); + Environment* environment = Environment::wrap(isolate); List exposed_class_list = internal::NamingUtil::get_exposed_original_class_list(); + HashMap> rpc_method_map; + + for (auto& script_class_info : environment->get_script_classes()) + { + if (script_class_info.rpc_config.is_empty()) + { + continue; + } + + HashSet& methods = rpc_method_map[script_class_info.js_class_name]; + + for (const auto& pair : script_class_info.rpc_config) + { + methods.insert(pair.key); + } + } + v8::Local array = v8::Array::New(isolate, exposed_class_list.size()); int index = 0; - for (auto it = exposed_class_list.begin(); it != exposed_class_list.end(); ++it) + for (auto& class_name : exposed_class_list) { JSB_HANDLE_SCOPE(isolate); - array->Set(context, index++, build_class_info(isolate, context, *it)).Check(); + array->Set(context, index++, build_class_info(isolate, context, class_name, rpc_method_map.getptr(class_name))).Check(); } info.GetReturnValue().Set(array); @@ -1142,4 +1197,3 @@ namespace jsb } } #endif // endif JSB_WITH_EDITOR_UTILITY_FUNCS - diff --git a/bridge/jsb_environment.cpp b/bridge/jsb_environment.cpp index 744397e9..f93b9db8 100644 --- a/bridge/jsb_environment.cpp +++ b/bridge/jsb_environment.cpp @@ -2135,6 +2135,13 @@ namespace jsb string_name_cache_.shrink(); source_map_cache_.clear(); + // `dispose()` explicitly destroys bound objects, and shutdown-time forced GC has caused + // fatal crashes while V8 is invoking weak callbacks. Skip forced collection in this state. + if (flags_ & EF_PreDispose) + { + return; + } + #if JSB_EXPOSE_GC_FOR_TESTING isolate_->RequestGarbageCollectionForTesting(v8::Isolate::kFullGarbageCollection); #else diff --git a/bridge/jsb_environment.h b/bridge/jsb_environment.h index 874ec4c6..e7f8477e 100644 --- a/bridge/jsb_environment.h +++ b/bridge/jsb_environment.h @@ -129,11 +129,11 @@ namespace jsb #if JSB_THREADING internal::DoubleBuffered async_calls_; #endif - + #if JSB_V8_CPPGC std::unique_ptr cpp_heap_; #endif - + // indirect lookup // only godot object classes are mapped HashMap godot_classes_index_; @@ -150,7 +150,7 @@ namespace jsb //TODO all exported default classes inherit native godot class (directly or indirectly) // they're only collected on a module loaded - internal::SArray script_classes_; + ScriptClassInfoArray script_classes_; StringNameCache string_name_cache_; @@ -592,6 +592,7 @@ namespace jsb jsb_force_inline ScriptClassInfoPtr get_script_class(const ScriptClassID p_class_id) { return script_classes_.get_value_scoped(p_class_id); } jsb_force_inline ScriptClassInfoConstPtr get_script_class(const ScriptClassID p_class_id) const { return script_classes_.get_value_scoped(p_class_id); } jsb_force_inline ScriptClassInfoPtr find_script_class(const ScriptClassID p_class_id) { return script_classes_.is_valid_index(p_class_id) ? script_classes_.get_value_scoped(p_class_id) : nullptr; } + jsb_force_inline const ScriptClassInfoArray& get_script_classes() { return script_classes_; } void get_statistics(Statistics& r_stats) const; diff --git a/scripts/jsb.editor/src/jsb.editor.codegen.ts b/scripts/jsb.editor/src/jsb.editor.codegen.ts index d15cfe41..616d6f94 100644 --- a/scripts/jsb.editor/src/jsb.editor.codegen.ts +++ b/scripts/jsb.editor/src/jsb.editor.codegen.ts @@ -7,6 +7,7 @@ import type { Node, PropertyInfo, Resource, + ResourceLoader, ResourceTypes, Script, Variant, @@ -500,6 +501,12 @@ const TypeMutations: Record = { move_child: mutate_parameter_type(names.get_parameter("child_node"), "NodePathMapChild"), remove_child: mutate_parameter_type("node", "NodePathMapChild"), validate_property: mutate_parameter_type("property", "GDictionary"), + rpc: [ + `${names.get_member("rpc")}>(method: Method, ...varargs: ${names.get_class("ResolveGodotRPCParameters")}): Error`, + ], + rpc_id: [ + `${names.get_member("rpc_id")}>(${names.get_parameter("peer_id")}: int64, method: Method, ...varargs: ${names.get_class("ResolveGodotRPCParameters")}): Error`, + ], }, }, // GObject: @@ -3746,6 +3753,25 @@ export class TSDCodeGen { } } + const rpc_interface_name = `__RPCMap${cls.name}`; + const rpc_interface_writer = cg.interface_(rpc_interface_name, undefined, cls.super && `__RPCMap${cls.super}`); + const rpc_methods = cls.rpc_methods ?? []; + for (const method_info of rpc_methods) { + rpc_interface_writer.property_( + method_info.name, + `(${this._types.make_args(method_info)}) => ${this._types.make_return(method_info)}`, + ); + } + + // Not really deprecated, but we don't want people using this. + cg.line("/** @deprecated Internal use. Does not exist at runtime. */"); + rpc_interface_writer.finish(); + + const godot_rpc_map_writer = class_cg.property_("__godotRPCMap"); + godot_rpc_map_writer.line(rpc_interface_name); + class_cg.line("/** @deprecated Internal use. Does not exist at runtime. */"); + godot_rpc_map_writer.finish(); + const overrides_interface_name = `__NameMap${cls.name}`; const overrides_interface_writer = cg.interface_( overrides_interface_name, @@ -3882,12 +3908,22 @@ export class SceneTSDCodeGen { export class ResourceTSDCodeGen { private _out_dir: string; private _resource_paths: string[]; + private _script_extensions: string[]; private _types: TypeDB; constructor(out_dir: string, resource_paths: string[]) { this._out_dir = out_dir; this._resource_paths = resource_paths; + const recognized_extensions = godot.ResourceLoader.get_recognized_extensions_for_type("Script"); + const length = recognized_extensions.size(); + const script_extensions = new Array(length); + + for (let i = 0; i < length; i++) { + script_extensions[i] = recognized_extensions.get(i); + } + + this._script_extensions = script_extensions; this._types = new TypeDB(); } @@ -3915,6 +3951,71 @@ export class ResourceTSDCodeGen { return tasks.submit(false); } + private get_script_rpc_info(resource_path: string): null | { class_name: string; methods: string[] } { + const extension = resource_path.slice(resource_path.lastIndexOf('.') + 1); + + if (!this._script_extensions.includes(extension)) { + return null; + } + + const resource_loader = godot.ResourceLoader; + + let script: undefined | Script; + + try { + script = resource_loader.load(resource_path) as Script; + } catch (e) { + console.warn(`Failed to generate RPC types for script: ${resource_path}`, e); + return null; + } + + const class_name: string = script.get_global_name(); + const rpc_config: null | GDictionary = script.get_rpc_config(); + + if (!class_name || !rpc_config) { + return null; + } + + const methods: string[] = [...rpc_config.keys()].filter(name => name).sort(); + + if (methods.length === 0) { + return null; + } + + return { class_name, methods }; + } + + private emit_script_rpc_types(module: ModuleWriter, resource_path: string) { + const script_rpc_info = this.get_script_rpc_info(resource_path); + + if (!script_rpc_info) { + return; + } + + module.add_import(script_rpc_info.class_name, resource_path); + + const imported_class_name = module.get_imports()[resource_path]?.default ?? script_rpc_info.class_name; + + const rpc_entries_interface = module.interface_(names.get_class("GodotUserRPCEntries")); + const entry_property = rpc_entries_interface.property_(resource_path); + const entry_writer = entry_property.object_(); + entry_writer.property_("type", imported_class_name); + + const rpc_map_property = entry_writer.property_("procedures"); + const rpc_map_writer = rpc_map_property.object_(); + + for (const method_name of script_rpc_info.methods) { + rpc_map_writer.property_(method_name, `${imported_class_name}[${JSON.stringify(method_name)}]`); + } + + rpc_map_writer.finish(); + rpc_map_property.finish(); + + entry_writer.finish(); + entry_property.finish(); + rpc_entries_interface.finish(); + } + private emit_resource_type(resource_path: string) { try { const helper = godot.GodotJSEditorHelper; @@ -3950,6 +4051,9 @@ export class ResourceTSDCodeGen { type_descriptor.finish(); resource_property.finish(); resource_types_interface.finish(); + + this.emit_script_rpc_types(module, resource_path); + module.finish(); file_writer.finish(); } finally { diff --git a/scripts/typings/godot.generated.d.ts b/scripts/typings/godot.generated.d.ts index dde2ce37..ad2c61a4 100644 --- a/scripts/typings/godot.generated.d.ts +++ b/scripts/typings/godot.generated.d.ts @@ -8,7 +8,25 @@ declare module "godot" { class Node = any> extends Object {} class Resource {} - class Script extends Resource {} + class Script extends Resource { + get_global_name(): StringName + get_rpc_config(): any + } + + namespace ResourceLoader { + enum CacheMode { + CACHE_MODE_IGNORE = 0, + CACHE_MODE_REUSE = 1, + CACHE_MODE_REPLACE = 2, + CACHE_MODE_IGNORE_DEEP = 3, + CACHE_MODE_REPLACE_DEEP = 4, + } + } + class ResourceLoader extends Object { + static load(path: string, type_hint?: string, cache_mode?: ResourceLoader.CacheMode): Resource; + static get_recognized_extensions_for_type(type: string): PackedStringArray; + } + interface ResourceTypes {} type GArrayCreateSource = @@ -62,7 +80,7 @@ declare module "godot" { push_back(value: GArrayElement): void; pop_back(): GArrayElement; has(value: GArrayElement): boolean; - find(what: GArrayElement, from: int64 = 0): int64; + find(what: GArrayElement, from?: int64): int64; } type byte = number; type int32 = number; @@ -151,6 +169,8 @@ declare module "godot" { class PackedStringArray { append(value: string): boolean; + get(index: int64): string; + size(): int64; } namespace FileAccess { diff --git a/scripts/typings/godot.minimal.d.ts b/scripts/typings/godot.minimal.d.ts index 6c3234e4..ee1e3c47 100644 --- a/scripts/typings/godot.minimal.d.ts +++ b/scripts/typings/godot.minimal.d.ts @@ -268,6 +268,7 @@ declare module "godot-jsb" { super: string; properties: Array; + rpc_methods?: Array; virtual_methods: Array; signals: Array; constants?: Array; diff --git a/scripts/typings/godot.mix.d.ts b/scripts/typings/godot.mix.d.ts index 3335d72f..b2638d7b 100644 --- a/scripts/typings/godot.mix.d.ts +++ b/scripts/typings/godot.mix.d.ts @@ -115,28 +115,49 @@ declare module "godot" { type ResolveGodotNameValue = Name extends keyof T ? T[Name] : "__godotNameMap" extends keyof T - ? Name extends keyof T["__godotNameMap"] - ? T["__godotNameMap"][Name] extends keyof T - ? T[T["__godotNameMap"][Name]] - : never - : never - : never; + ? Name extends keyof T["__godotNameMap"] + ? T["__godotNameMap"][Name] extends keyof T + ? T[T["__godotNameMap"][Name]] + : never + : never + : never; type ResolveGodotNameParameters = Name extends GodotDynamicDispatchName ? GAny[] : ResolveGodotName extends keyof T - ? T[ResolveGodotName] extends { - bivarianceHack(...args: infer P extends GAny[]): void | GAny; - }["bivarianceHack"] - ? P - : never - : never; + ? T[ResolveGodotName] extends { + bivarianceHack(...args: infer P extends GAny[]): void | GAny; + }["bivarianceHack"] + ? P + : never + : never; type ResolveGodotReturnType = Name extends GodotDynamicDispatchName ? void | GAny : ResolveGodotName extends keyof T - ? T[ResolveGodotName] extends (...args: any[]) => infer R - ? R - : never - : never; + ? T[ResolveGodotName] extends (...args: any[]) => infer R + ? R + : never + : never; + + interface GodotUserRPCEntries {} + + type GodotNativeRPCMap = "__godotRPCMap" extends keyof T ? T["__godotRPCMap"] : {}; + type GodotUserRPCEntry = GodotUserRPCEntries[keyof GodotUserRPCEntries]; + type GodotUserRPCType = GodotUserRPCEntry extends { type: infer Type } ? Type : never; + type GodotUserRPCMap =[T] extends [GodotUserRPCType] + ? Extract extends { procedures: infer Procedures; } + ? Procedures + : {} + : {}; + type GodotRPCMap = GodotNativeRPCMap & GodotUserRPCMap; + type GodotRPCNames = keyof GodotRPCMap; + type ResolveGodotRPCMapParameters = Name extends keyof Map + ? Map[Name] extends { + bivarianceHack(...args: infer P extends GAny[]): void | GAny; + }["bivarianceHack"] + ? P + : never + : never; + type ResolveGodotRPCParameters = ResolveGodotRPCMapParameters, Name>; /** * Godot has many APIs that are a form of dynamic dispatch, i.e., they take the name of a function or property and diff --git a/tests/project/typings/godot.minimal.d.ts b/tests/project/typings/godot.minimal.d.ts index 7c53efa0..90e4ceba 100644 --- a/tests/project/typings/godot.minimal.d.ts +++ b/tests/project/typings/godot.minimal.d.ts @@ -267,6 +267,7 @@ declare module "godot-jsb" { super: string; properties: Array; + rpc_methods?: Array; virtual_methods: Array; signals: Array; constants?: Array; diff --git a/tests/project/typings/godot.mix.d.ts b/tests/project/typings/godot.mix.d.ts index 3335d72f..e9a47766 100644 --- a/tests/project/typings/godot.mix.d.ts +++ b/tests/project/typings/godot.mix.d.ts @@ -138,6 +138,35 @@ declare module "godot" { : never : never; + interface __EmptyRPCMap {} + + interface GodotUserRPCEntries {} + + type GodotNativeRPCMap = "__godotRPCMap" extends keyof T ? T["__godotRPCMap"] : __EmptyRPCMap; + type NeverToEmptyRpcMap = [Map] extends [never] ? __EmptyRPCMap : Map; + type GodotUserRPCMap = NeverToEmptyRpcMap< + { + [K in keyof GodotUserRPCEntries]: GodotUserRPCEntries[K] extends { + type: infer Type; + procedures: infer Procedures; + } + ? T extends Type + ? Procedures + : never + : never; + }[keyof GodotUserRPCEntries] + >; + type GodotRPCMap = GodotNativeRPCMap & GodotUserRPCMap; + type GodotRPCNames = keyof GodotRPCMap; + type ResolveGodotRPCMapParameters = Name extends keyof Map + ? Map[Name] extends { + bivarianceHack(...args: infer P extends GAny[]): void | GAny; + }["bivarianceHack"] + ? P + : never + : never; + type ResolveGodotRPCParameters = ResolveGodotRPCMapParameters, Name>; + /** * Godot has many APIs that are a form of dynamic dispatch, i.e., they take the name of a function or property and * then operate on the value matching the name. TypeScript is powerful enough to allow us to type these APIs. diff --git a/weaver-editor/jsb_editor_plugin.cpp b/weaver-editor/jsb_editor_plugin.cpp index 772d871f..354accd1 100644 --- a/weaver-editor/jsb_editor_plugin.cpp +++ b/weaver-editor/jsb_editor_plugin.cpp @@ -152,9 +152,15 @@ bool GodotJSEditorPlugin::delete_file(const String &p_file) String GodotJSEditorPlugin::mutate_types(const String& p_content) { + auto should_ignore_identifier = [](const String& p_identifier) -> bool + { + // Internal utility types are double underscore prefixed + return p_identifier.begins_with("__"); + }; + // Regex obviously isn't the best tool for the job and this regex will, for example, match some generic parameter // names. However, for now, it does the job. - RegEx type_regex("(?m)(?:=>|[:|&<=,{]|\\s+(?:type|enum|extends)\\s+|\\s+(?:class|interface)(?:<[^>]+>)?\\s+)\\s*([A-Z]\\w+)(?:\\.([A-Z]\\w+))*"); + RegEx type_regex("(?m)(?:=>|[:|&<=,{\\[]|\\b(?:type|enum|extends|keyof|infer|typeof|implements|as|is|in|satisfies)\\s+|\\s+(?:class|interface)(?:<[^>]+>)?\\s+)\\s*([A-Z]\\w+)(?:\\.([A-Z]\\w+))*"); TypedArray type_matches = type_regex.search_all(p_content); String result = p_content; for (int match_index = type_matches.size() - 1; match_index >= 0; match_index--) @@ -178,11 +184,20 @@ String GodotJSEditorPlugin::mutate_types(const String& p_content) String component = components[i]; start = end - component.length(); identifier = result.substr(start, end - start); + + if (should_ignore_identifier(identifier)) + { + end = start - 1; + continue; + } + replacement = jsb::internal::NamingUtil::get_class_name(identifier); + if (replacement != identifier) { result = result.substr(0, start) + replacement + result.substr(end); } + end = start - 1; } } @@ -190,6 +205,12 @@ String GodotJSEditorPlugin::mutate_types(const String& p_content) start = match->get_start(1); end = match->get_end(1); identifier = result.substr(start, end - start); + + if (should_ignore_identifier(identifier)) + { + continue; + } + replacement = jsb::internal::NamingUtil::get_class_name(identifier); if (replacement != identifier && identifier != "Array") // Godot Array is already GArray, Array is the JS type. {