Skip to content

Commit

Permalink
Improve .NET APIs for JS import & export (microsoft#248)
Browse files Browse the repository at this point in the history
  • Loading branch information
jasongin authored Mar 26, 2024
1 parent 08c05d5 commit 5cce1cc
Show file tree
Hide file tree
Showing 26 changed files with 619 additions and 174 deletions.
20 changes: 1 addition & 19 deletions src/NodeApi.DotNetHost/JSMarshaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,24 +140,6 @@ private string ToCamelCase(string name)
return sb.ToString();
}

/// <summary>
/// Converts a value to a JS value.
/// </summary>
public JSValue From<T>(T value)
{
JSValue.From<T> converter = GetToJSValueDelegate<T>();
return converter(value);
}

/// <summary>
/// Converts a JS value to a requested type.
/// </summary>
public T To<T>(JSValue value)
{
JSValue.To<T> converter = GetFromJSValueDelegate<T>();
return converter(value);
}

/// <summary>
/// Checks whether a type is converted to a JavaScript built-in type.
/// </summary>
Expand Down Expand Up @@ -2238,7 +2220,7 @@ private LambdaExpression BuildConvertToJSValueExpression(Type fromType)
{
statements = new[] { valueParameter };
}
else if (fromType == typeof(object) || !fromType.IsPublic)
else if (fromType == typeof(object) || !(fromType.IsPublic || fromType.IsNestedPublic))
{
// Marshal unknown or nonpublic type as external, so at least it can be round-tripped.
Expression objectExpression = fromType.IsValueType ?
Expand Down
32 changes: 23 additions & 9 deletions src/NodeApi.DotNetHost/JSRuntimeContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,42 +15,56 @@ public static class JSRuntimeContextExtensions
/// <summary>
/// Imports a module or module property from JavaScript and converts it to an interface.
/// </summary>
/// <typeparam name="T">Type of the value being imported.</typeparam>
/// <typeparam name="T">.NET type that the imported JS value will be marshalled to.</typeparam>
/// <param name="module">Name of the module being imported, or null to import a
/// global property. This is equivalent to the value provided to <c>import</c> or
/// <c>require()</c> in JavaScript. Required if <paramref name="property"/> is null.</param>
/// <param name="property">Name of a property on the module (or global), or null to import
/// the module object. Required if <paramref name="module"/> is null.</param>
/// <returns>The imported value.</returns>
/// <param name="marshaller">JS marshaller instance to use to convert the imported value
/// to a .NET type.</param>
/// <returns>The imported value, marshalled to the specified .NET type.</returns>
/// <exception cref="ArgumentNullException">Both <paramref cref="module" /> and
/// <paramref cref="property" /> are null.</exception>
public static T Import<T>(
this JSRuntimeContext runtimeContext,
string? module,
string? property)
string? property,
bool esModule,
JSMarshaller marshaller)
{
JSValue jsValue = runtimeContext.Import(module, property);
return JSMarshaller.Current.To<T>(jsValue);
if (marshaller == null) throw new ArgumentNullException(nameof(marshaller));

JSValue jsValue = runtimeContext.Import(module, property, esModule);
return marshaller.FromJS<T>(jsValue);
}

/// <summary>
/// Imports a module or module property from JavaScript and converts it to an interface.
/// </summary>
/// <typeparam name="T">Type of the value being imported.</typeparam>
/// <typeparam name="T">.NET type that the imported JS value will be marshalled to.</typeparam>
/// <param name="module">Name of the module being imported, or null to import a
/// global property. This is equivalent to the value provided to <c>import</c> or
/// <c>require()</c> in JavaScript. Required if <paramref name="property"/> is null.</param>
/// <param name="property">Name of a property on the module (or global), or null to import
/// the module object. Required if <paramref name="module"/> is null.</param>
/// <returns>The imported value.</returns>
/// <param name="marshaller">JS marshaller instance to use to convert the imported value
/// to a .NET type.</param>
/// <returns>The imported value, marshalled to the specified .NET type.</returns>
/// <exception cref="ArgumentNullException">Both <paramref cref="module" /> and
/// <paramref cref="property" /> are null.</exception>
public static T Import<T>(
this NodejsEnvironment nodejs,
string? module,
string? property)
string? property,
bool esModule,
JSMarshaller marshaller)
{
if (marshaller == null) throw new ArgumentNullException(nameof(marshaller));

JSValueScope scope = nodejs;
return scope.RuntimeContext.Import<T>(module, property);
return scope.RuntimeContext.Import<T>(module, property, esModule, marshaller);
}

// TODO: ImportAsync()
}
35 changes: 15 additions & 20 deletions src/NodeApi.DotNetHost/ManagedHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,6 @@ public sealed class ManagedHost : JSEventEmitter, IDisposable

private JSValueScope? _rootScope;

/// <summary>
/// Strong reference to the JS object that is the exports for this module.
/// </summary>
/// <remarks>
/// The exports object has module APIs such as `require()` and `load()`, along with
/// top-level .NET namespaces like `System` and `Microsoft`.
/// </remarks>
private readonly JSReference _exports;

/// <summary>
/// Component that dynamically exports types from loaded assemblies.
/// </summary>
Expand Down Expand Up @@ -140,24 +131,22 @@ JSValue removeListener(JSCallbackArgs args)
AutoCamelCase = false,
};

// Save the exports object, on which top-level namespaces will be defined.
_exports = new JSReference(exports);

_typeExporter = new()
// The type exporter will define top-level namespace properties on the exports object.
_typeExporter = new(JSMarshaller.Current, exports)
{
// Delay-loading is enabled by default, but can be disabled with this env variable.
IsDelayLoadEnabled =
Environment.GetEnvironmentVariable("NODE_API_DELAYLOAD") != "0"
};

// Export the System.Runtime and System.Console assemblies by default.
_typeExporter.ExportAssemblyTypes(typeof(object).Assembly, exports);
_typeExporter.ExportAssemblyTypes(typeof(object).Assembly);
_loadedAssembliesByName.Add(
typeof(object).Assembly.GetName().Name!, typeof(object).Assembly);

if (typeof(Console).Assembly != typeof(object).Assembly)
{
_typeExporter.ExportAssemblyTypes(typeof(Console).Assembly, exports);
_typeExporter.ExportAssemblyTypes(typeof(Console).Assembly);
_loadedAssembliesByName.Add(
typeof(Console).Assembly.GetName().Name!, typeof(Console).Assembly);
}
Expand Down Expand Up @@ -222,11 +211,17 @@ public static napi_value InitializeModule(napi_env env, napi_value exports)
{
JSObject exportsObject = (JSObject)new JSValue(exports, scope);

// Save the require() function that was passed in by the init script.
JSValue require = exportsObject["require"];
if (require.IsFunction())
// Save the require() and import() functions that were passed in by the init script.
JSValue requireFunction = exportsObject["require"];
if (requireFunction.IsFunction())
{
JSRuntimeContext.Current.RequireFunction = (JSFunction)requireFunction;
}

JSValue importFunction = exportsObject["import"];
if (importFunction.IsFunction())
{
JSRuntimeContext.Current.Require = require;
JSRuntimeContext.Current.ImportFunction = (JSFunction)importFunction;
}

ManagedHost host = new(exportsObject)
Expand Down Expand Up @@ -513,7 +508,7 @@ private Assembly LoadAssembly(string assemblyNameOrFilePath, bool allowNativeLib
assembly = _loadContext.LoadFromAssemblyPath(assemblyFilePath);
#endif

_typeExporter.ExportAssemblyTypes(assembly, (JSObject)_exports.GetValue()!.Value);
_typeExporter.ExportAssemblyTypes(assembly);
}
catch (BadImageFormatException)
{
Expand Down
98 changes: 82 additions & 16 deletions src/NodeApi.DotNetHost/TypeExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ namespace Microsoft.JavaScript.NodeApi.DotNetHost;
/// <summary>
/// Dynamically exports .NET types to JS.
/// </summary>
internal class TypeExporter
/// <remarks>
/// Exporting a .NET type:
/// - Defines equivalent namespaced JS class prototype is defined, with a constructor function
/// that calls back to the.NET constructor.
/// - Defines static and instance properties and methods on the class prototype. Initially all
/// of them are stubs (if <see cref="TypeExporter.IsDelayLoadEnabled" /> is true (the default),
/// but on first access each property gets redefined to call back to the corresponding .NET
/// property or method. The callback uses marshalling code dynamically generated by a
/// <see cref="JSMarshaller" />.
/// - Registers a mapping between the .NET type and JS class/constructor object with the
/// <see cref="JSRuntimeContext" />, for use in any marshalling operations.
/// </remarks>
public class TypeExporter
{
/// <summary>
/// Mapping from top-level namespace names like `System` and `Microsoft` to
Expand All @@ -41,12 +53,24 @@ internal class TypeExporter
/// </summary>
private readonly JSMarshaller _marshaller;

private readonly JSReference? _namespaces;

/// <summary>
/// Creates a new instance of the <see cref="TypeExporter" /> class.
/// </summary>
public TypeExporter()
/// <param name="marshaller">Used by the exporter to dynamically generate callback marshalling
/// code for exported members. Note the marshaller's <see cref="JSMarshaller.AutoCamelCase" />
/// property controls the casing of members of exported types.</param>
/// <param name="namespaces">Optional JS object where top-level .NET namespace properties
/// (like "System") will be defined for exported types.</param>
public TypeExporter(JSMarshaller marshaller, JSObject? namespaces = null)
{
_marshaller = JSMarshaller.Current;
_marshaller = marshaller;

if (namespaces != null)
{
_namespaces = new JSReference(namespaces.Value);
}
}

/// <summary>
Expand All @@ -55,9 +79,22 @@ public TypeExporter()
/// </summary>
public bool IsDelayLoadEnabled { get; set; } = true;

public void ExportAssemblyTypes(Assembly assembly, JSObject exports)
/// <summary>
/// Exports all types from a .NET assembly to JavaScript.
/// </summary>
/// <remarks>
/// If a JS "namespaces" object was passed to the <see cref="TypeExporter" /> constructor,
/// this method may register additional top-level namespaces on that object for types in the
/// assembly.
/// <para />
/// If <see cref="IsDelayLoadEnabled" /> is true (the default), then individual types in the
/// assembly are not fully exported until they are referenced either directly or by a
/// dependency.
/// </remarks>
public void ExportAssemblyTypes(Assembly assembly)
{
Trace($"> ManagedHost.LoadAssemblyTypes({assembly.GetName().Name})");
string assemblyName = assembly.GetName().Name!;
Trace($"> {nameof(TypeExporter)}.ExportAssemblyTypes({assemblyName})");
int count = 0;

List<TypeProxy> typeProxies = new();
Expand All @@ -83,8 +120,14 @@ public void ExportAssemblyTypes(Assembly assembly, JSObject exports)
{
// Export a new top-level namespace.
parentNamespace = new NamespaceProxy(namespaceParts[0], null, this);
exports[namespaceParts[0]] = parentNamespace.Value;
_exportedNamespaces.Add(namespaceParts[0], parentNamespace);

if (_namespaces != null)
{
// Add a property on the namespaces JS object.
JSObject namespacesObject = (JSObject)_namespaces.GetValue()!.Value;
namespacesObject[namespaceParts[0]] = parentNamespace.Value;
}
}

for (int i = 1; i < namespaceParts.Length; i++)
Expand Down Expand Up @@ -147,7 +190,7 @@ public void ExportAssemblyTypes(Assembly assembly, JSObject exports)
ExportExtensionMethod(extensionMethod);
}

Trace($"< ManagedHost.LoadAssemblyTypes({assembly.GetName().Name}) => {count} types");
Trace($"< {nameof(TypeExporter)}.ExportAssemblyTypes({assemblyName}) => {count} types");
}

private void RegisterDerivedType(TypeProxy derivedType, Type? baseOrInterfaceType = null)
Expand Down Expand Up @@ -265,7 +308,7 @@ private static bool IsExtensionTargetTypeSupported(Type targetType, string exten
return true;
}

public NamespaceProxy? GetNamespaceProxy(string ns)
internal NamespaceProxy? GetNamespaceProxy(string ns)
{
string[] namespaceParts = ns.Split('.');
if (!_exportedNamespaces.TryGetValue(
Expand All @@ -287,7 +330,7 @@ private static bool IsExtensionTargetTypeSupported(Type targetType, string exten
return namespaceProxy;
}

public TypeProxy? GetTypeProxy(Type type)
internal TypeProxy? GetTypeProxy(Type type)
{
if (type.IsConstructedGenericType)
{
Expand All @@ -314,11 +357,11 @@ private static bool IsExtensionTargetTypeSupported(Type targetType, string exten
/// never actually used. The default is from <see cref="IsDelayLoadEnabled"/>.</param>
/// <returns>A strong reference to a JS object that represents the exported type, or null
/// if the type could not be exported.</returns>
public JSReference? TryExportType(Type type, bool? deferMembers = null)
internal JSReference? TryExportType(Type type, bool? deferMembers = null)
{
try
{
return ExportType(type, deferMembers ?? IsDelayLoadEnabled);
return ExportType(type, deferMembers);
}
catch (NotSupportedException ex)
{
Expand All @@ -332,7 +375,24 @@ private static bool IsExtensionTargetTypeSupported(Type targetType, string exten
}
}

private JSReference ExportType(Type type, bool deferMembers)
/// <summary>
/// Exports a specific .NET type to JS.
/// </summary>
/// <param name="type">The .NET type to export.</param>
/// <param name="deferMembers">True to delay exporting of all type members until each one is
/// accessed. If false, all type members are immediately exported, which may cascade to
/// exporting many additional types referenced by the members, including members that are
/// never actually used. The default is from <see cref="IsDelayLoadEnabled"/>.</param>
/// <returns>A strong reference to a JS object that represents the exported type.</returns>
/// <exception cref="NotSupportedException">The .NET type cannot be exported.</exception>
/// <remarks>
/// This method does NOT register namespaces for the exported type on the JS "namespaces"
/// object (if one was passed to the <see cref="TypeExporter" /> constructor). It is
/// sufficient for explicit marshalling of the exported type using .NET code, but not
/// for dynamic access of the .NET type from JS code. Use <see cref="ExportAssemblyTypes()" />
/// instead for full namespace export.
/// </remarks>
public JSReference ExportType(Type type, bool? deferMembers = null)
{
if (!IsSupportedType(type))
{
Expand All @@ -358,7 +418,7 @@ private JSReference ExportType(Type type, bool deferMembers)
}
else
{
return ExportClass(type, deferMembers);
return ExportClass(type, deferMembers ?? IsDelayLoadEnabled);
}
}
else
Expand Down Expand Up @@ -537,9 +597,15 @@ private void ExportTypeIfSupported(Type dependencyType, bool deferMembers)
#endif
IsSupportedType(dependencyType))
{
TypeProxy typeProxy = GetTypeProxy(dependencyType) ??
throw new InvalidOperationException(
$"Type proxy not found for dependency: {dependencyType.FormatName()}");
TypeProxy? typeProxy = GetTypeProxy(dependencyType);
if (typeProxy == null)
{
ExportAssemblyTypes(dependencyType.Assembly);
typeProxy = GetTypeProxy(dependencyType) ??
throw new InvalidOperationException(
$"Dependency type was not exported: {dependencyType.FormatName()}");
}

typeProxy.Export();
}
}
Expand Down
Loading

0 comments on commit 5cce1cc

Please sign in to comment.