Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve date/time marshalling #306

Merged
merged 3 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Docs/dates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Date and time types in .NET and JavaScript

## JS Date / .NET DateTime & DateTimeOffset
There is not a clean mapping between the built-in types for dates and times in .NET and JS.
In particular the built-in JavaScript `Date` class has very limited and somewhat confusing
functionality. For this reason many applications use the popular `moment.js` library, but this
project prefers to avoid depending on an external library. Also [a new "Temporal" API is
proposed for standardization](https://tc39.es/proposal-temporal/docs/), but it is not widely
available yet.

A JavaScript [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date)
object is fundamentally a wrapper around a single primitive numeric value that is a UTC timestamp
(milliseconds since the epoch). It does not hold any other state related to offsets or time zones.
This UTC primitive value is returned by the `Date.valueOf()` function, and is one type of value
accepted by the `Date()` constructor. The confusing thing about it is that other constructors and
most `Date` methods operate in the context of the current local time zone, automatically converting
to/from UTC as needed. That includes the default `toString()` method, though alternatives like
`toUTCString()` and `toISOString()` can get the time in UTC instead.

In .NET, both `DateTime` and `DateTimeOffset` are widely used. While the latter is more modern
and fully-featured, the simpler `DateTime` is still sufficient for many scenarios. So for best
interoperability, both types of .NET values are convertible to and from JS `Date` values. This is
accomplished by adding either a `kind` or `offset` property to a regular `Date` object.

```TypeScript
type DateTime = Date | { kind?: 'utc' | 'local' | 'unspecified' }
```

When a .NET `DateTime` is marshalled to a JS `Date`, the date's UTC timestamp value becomes the
JS `Date` value, regardless of the `DateTime.Kind` property. Then the `kind` property is added to
facilitate consistent round-tripping, so that a `DateTime` can be passed from .NET to JS and
back to .NET without its `Kind` changing. (As noted above, the JS `Date.toString()` always
displays local time, and that remains true even for a `Date` with `kind == 'utc'`.) If a
regular JS `Date` object without a `kind` hint is marshalled to .NET, it becomes a `DateTime`
with `Utc` kind. (Defaulting to `Unspecified` would be more likely to result in undesirable
conversions to/from local-time.)

```TypeScript
type DateTimeOffset = Date | { offset?: number }
```

When a .NET `DateTimeOffset` is marshalled to a JS `Date`, the UTC timestamp value _without the
offset_ becomes the JS `Date` value. Then the `offset` property is added to the object. The
`offset` is a positive or negative integer number of minutes, equivalent to
[DateTimeOffset.TotalOffsetMinumtes](https://learn.microsoft.com/en-us/dotnet/api/system.datetimeoffset.totaloffsetminutes).
Additionally, the `toString()` method of the `Date` object is overridden such that it displays
the time _with the offset applied_, followed by the offset, in the form
`YYYY-MM-DD HH:mm:SS (+/-)HH:mm`, consistent with how .NET `DateTimeOffset` is displayed.
If a regular JS `Date` object without an `offset` value is marshalled to .NET, it becomes a
`DateTimeOffset` with zero offset (not local time-zone offset).

## JS number / .NET TimeSpan
JavaScript lacks a built-in type for representing time spans (at least until the "Temporal" API
is standardized). The common practice is to represent basic time spans as a number of milliseconds.
So a .NET `TimeSpan` is marshalled to or from a simple JS `number` value.

Note the `Date.offset` property introduced above is intentionally NOT a millisecond timespan value.
It is a whole (positive or negative) number of minutes, because `DateTimeOffset` does not support
second or millisecond precision for offsets.
26 changes: 12 additions & 14 deletions Docs/typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,18 @@ properties. So C# `(string A, int B)` becomes TypeScript `[string, number]`, not

### Special types

| C# Type | TypeScript Projection |
|--------------------|-----------------------|
| `Task` | `Promise<void>` |
| `Task<T>` | `Promise<T>` |
| `DateTime` | `Date` |
| `DateTimeOffset` | `[Date, number]` |
| `TimeSpan` | `number` |
| `BigInteger` | `BigInt` |
| `Tuple<A, B, ...>` | `[A, B, ...]` |
| `Stream` | `Duplex` |

Dates marshalled from JavaScript will always be `Utc` kind. A `TimeSpan` is projected to JavaScript
as a decimal number of milliseconds. A `DateTimeOffset` is projected as a tuple of the UTC date-time
and the offset in (positive or negative) milliseconds.
| C# Type | TypeScript Projection |
|--------------------|-------------------------|
| `Task` | `Promise<void>` |
| `Task<T>` | `Promise<T>` |
| `BigInteger` | `BigInt` |
| `Tuple<A, B, ...>` | `[A, B, ...]` |
| `Stream` | `Duplex` |
| `DateTime` | `Date \| { kind?: 'utc' \| 'local' \| 'unspecified'}` |
| `DateTimeOffset` | `Date \| { offset?: number }` (Date is UTC; offset is +/- minutes) |
| `TimeSpan` | `number` (milliseconds) |

For more about date and time marshalling, see [Date and time types in .NET and JavaScript](./dates.md).

### Methods with `ref` or `out` parameters
JavaScript does not support `ref` or `out` parameters, so some transformations are applied
Expand Down
38 changes: 30 additions & 8 deletions src/NodeApi.DotNetHost/JSMarshaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2036,13 +2036,24 @@ Expression TupleItem(int index) => InlineOrInvoke(
}
else if (toType == typeof(TimeSpan))
{
MethodInfo asString = typeof(JSValue).GetExplicitConversion(
typeof(JSValue), typeof(string));
MethodInfo asDouble = typeof(JSValue).GetExplicitConversion(
typeof(JSValue), typeof(double));
MethodInfo toTimeSpan = typeof(TimeSpan).GetStaticMethod(
nameof(TimeSpan.Parse), new[] { typeof(string) });
nameof(TimeSpan.FromMilliseconds));
statements = new[]
{
Expression.Call(toTimeSpan, Expression.Call(asDouble, valueParameter)),
};
}
else if (toType == typeof(DateTimeOffset))
{
MethodInfo asJSDate = typeof(JSDate).GetExplicitConversion(
typeof(JSValue), typeof(JSDate));
MethodInfo toDateTimeOffset = typeof(JSDate).GetInstanceMethod(
nameof(JSDate.ToDateTimeOffset));
statements = new[]
{
Expression.Call(toTimeSpan, Expression.Call(asString, valueParameter)),
Expression.Call(Expression.Call(asJSDate, valueParameter), toDateTimeOffset),
};
}
else if (toType == typeof(Guid))
Expand Down Expand Up @@ -2343,13 +2354,24 @@ Expression TupleItem(int index) => InlineOrInvoke(
}
else if (fromType == typeof(TimeSpan))
{
MethodInfo toString = typeof(TimeSpan).GetInstanceMethod(
nameof(TimeSpan.ToString), []);
PropertyInfo doubleValue = typeof(TimeSpan).GetInstanceProperty(
nameof(TimeSpan.TotalMilliseconds));
MethodInfo asJSValue = typeof(JSValue).GetImplicitConversion(
typeof(string), typeof(JSValue));
typeof(double), typeof(JSValue));
statements = new[]
{
Expression.Call(asJSValue, Expression.Call(valueParameter, toString)),
Expression.Call(asJSValue, Expression.Property(valueParameter, doubleValue)),
};
}
else if (fromType == typeof(DateTimeOffset))
{
MethodInfo fromDateTimeOffset = typeof(JSDate).GetStaticMethod(
nameof(JSDate.FromDateTimeOffset));
MethodInfo asJSValue = typeof(JSDate).GetImplicitConversion(
typeof(JSDate), typeof(JSValue));
statements = new[]
{
Expression.Call(asJSValue, Expression.Call(fromDateTimeOffset, valueParameter)),
};
}
else if (fromType == typeof(Guid))
Expand Down
51 changes: 43 additions & 8 deletions src/NodeApi.Generator/TypeDefinitionsGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,14 @@ function importAotModule(moduleName) {
private bool _emitDisposable;
private bool _emitDuplex;
private bool _emitType;
private bool _emitDateTime;
private bool _emitDateTimeOffset;

/// <summary>
/// When generating type definitions for a system assembly, some supplemental type definitions
/// need an extra namespace qualifier to prevent conflicts with types in the "System" namespace.
/// </summary>
private readonly bool _isSystemAssembly;

public static void GenerateTypeDefinitions(
string assemblyPath,
Expand Down Expand Up @@ -356,6 +364,7 @@ public TypeDefinitionsGenerator(
_assembly = assembly;
_referenceAssemblies = referenceAssemblies;
_imports = new HashSet<string>();
_isSystemAssembly = assembly.GetName().Name!.StartsWith("System.");
}

public bool ExportAll { get; set; }
Expand Down Expand Up @@ -723,19 +732,36 @@ interface IType {
if (_emitDisposable)
{
s.Insert(insertIndex, @"
interface IDisposable {
dispose(): void;
}
interface IDisposable { dispose(): void; }
");
}

if (_emitDuplex)
{
s.Insert(insertIndex, @"
import { Duplex } from 'stream';
");
}

if (_emitDateTimeOffset)
{
s.Insert(insertIndex, _isSystemAssembly ? @"
declare namespace js { type DateTimeOffset = Date | { offset?: number } }
" : @"
type DateTimeOffset = Date | { offset?: number }
");
}

if (_emitDateTime)
{
s.Insert(insertIndex, _isSystemAssembly ? @"
declare namespace js { type DateTime = Date | { kind?: 'utc' | 'local' | 'unspecified' } }
" : @"
type DateTime = Date | { kind?: 'utc' | 'local' | 'unspecified' }
");
}
}

private static string GetGenericParams(Type type)
{
string genericParams = string.Empty;
Expand Down Expand Up @@ -1512,7 +1538,7 @@ private string GetTSType(
return tsType;
}

string? specialType = type.FullName switch
string? primitiveType = type.FullName switch
{
"System.Void" => "void",
"System.Boolean" => "boolean",
Expand All @@ -1527,16 +1553,15 @@ private string GetTSType(
"System.Single" => "number",
"System.Double" => "number",
"System.String" => "string",
"System.DateTime" => "Date",
"System.TimeSpan" => "string",
"System.TimeSpan" => "number",
"System.Guid" => "string",
"System.Numerics.BigInteger" => "bigint",
_ => null,
};

if (specialType != null)
if (primitiveType != null)
{
tsType = specialType;
tsType = primitiveType;
}
#if NETFRAMEWORK || NETSTANDARD
else if (type.IsGenericTypeParameter())
Expand Down Expand Up @@ -1636,6 +1661,16 @@ private string GetTSType(
tsType = "Duplex";
_emitDuplex = true;
}
else if (type.FullName == typeof(DateTime).FullName)
{
_emitDateTime = true;
tsType = (_isSystemAssembly ? "js." : "") + type.Name;
}
else if (type.FullName == typeof(DateTimeOffset).FullName)
{
_emitDateTimeOffset = true;
tsType = (_isSystemAssembly ? "js." : "") + type.Name;
}
else if (IsExported(type))
{
// Types exported from a module are not namespaced.
Expand Down
84 changes: 78 additions & 6 deletions src/NodeApi/JSDate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,97 @@ public JSDate(string dateString)

public static JSDate FromDateTime(DateTime value)
{
long dateValue = new DateTimeOffset(value.ToUniversalTime())
.ToUnixTimeMilliseconds();
return new JSDate(dateValue);
DateTimeKind kind = value.Kind;

// JS Date values are always represented with a underlying UTC epoch value,
// so local times must be converted to UTC. Unspecified kind is treated as local.
if (kind != DateTimeKind.Utc)
{
value = value.ToUniversalTime();
}

long dateValue = new DateTimeOffset(value).ToUnixTimeMilliseconds();
JSDate jsDate = new(dateValue);

// Add an extra property that allows round-tripping the DateTimeKind.
jsDate._value.SetProperty("kind", kind.ToString().ToLowerInvariant());

return jsDate;
}

public DateTime ToDateTime()
{
return DateTimeOffset.FromUnixTimeMilliseconds(DateValue).UtcDateTime;
// JS Date values are always represented with a underlying UTC epoch value.
// FromUnixTimeMilliseconds expects a value in UTC and produces a result with 0 offset.
DateTimeOffset utcValue = DateTimeOffset.FromUnixTimeMilliseconds(DateValue);
DateTime value = utcValue.UtcDateTime;

// Check for the kind hint. If absent, default to UTC, not Unspecified.
JSValue kindHint = _value.GetProperty("kind");
if (kindHint.IsString() && Enum.TryParse((string)kindHint, true, out DateTimeKind kind) &&
kind != DateTimeKind.Utc)
{
value = DateTime.SpecifyKind(value.ToLocalTime(), kind);
}

return value;
}

public static JSDate FromDateTimeOffset(DateTimeOffset value)
{
long dateValue = value.ToUnixTimeMilliseconds();
return new JSDate(dateValue);
JSDate jsDate = new(dateValue);

jsDate._value.SetProperty("offset", value.Offset.TotalMinutes);
jsDate._value.SetProperty("toString", new JSFunction(JSDateWithOffsetToString));

return jsDate;
}

private static JSValue JSDateWithOffsetToString(JSCallbackArgs args)
{
JSValue thisDate = args.ThisArg;
JSValue value = thisDate.CallMethod("valueOf");
JSValue offset = thisDate.GetProperty("offset");

if (!offset.IsNumber() || !value.IsNumber() || double.IsNaN((double)value))
{
JSValue dateClass = JSRuntimeContext.Current.Import(null, "Date");
return dateClass.GetProperty("prototype").GetProperty("toString").Call(thisDate);
}

// Call toISOString on another Date instance with the offset applied.
int offsetValue = (int)offset;
JSDate offsetDate = new((long)thisDate.CallMethod("valueOf") + offsetValue * 60 * 1000);
JSValue isoString = offsetDate._value.CallMethod("toISOString");

string offsetSign = offsetValue < 0 ? "-" : "+";
offsetValue = Math.Abs(offsetValue);
int offsetHours = offsetValue / 60;
int offsetMinutes = offsetValue % 60;

// Convert the ISO string to a string with the offset.
return ((string)isoString).Replace("T", " ").Replace("Z", "") + " " + offsetSign +
offsetHours.ToString("D2") + ":" + offsetMinutes.ToString("D2");
}

public DateTimeOffset ToDateTimeOffset()
{
return DateTimeOffset.FromUnixTimeMilliseconds(DateValue);
JSValue offset = _value.GetProperty("offset");
if (offset.IsNumber())
{
// FromUnixTimeMilliseconds expects a value in UTC and produces a result with 0 offset.
// The offset must be added to UTC when constructing the DateTimeOffset.
DateTimeOffset utcValue = DateTimeOffset.FromUnixTimeMilliseconds(DateValue);
TimeSpan offsetTime = TimeSpan.FromMinutes((double)offset);
return new DateTimeOffset(
new DateTime(utcValue.DateTime.Add(offsetTime).Ticks),
offsetTime);
}
else
{
return new DateTimeOffset(ToDateTime());
}
}

/// <summary>
Expand Down
14 changes: 12 additions & 2 deletions test/TestCases/napi-dotnet/ComplexTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,19 @@ public static Memory<int> Slice(Memory<int> array, int start, int length)

public static TestEnum TestEnum { get; set; }

public static DateTime Date { get; set; } = new DateTime(2023, 2, 1, 0, 0, 0, DateTimeKind.Utc);
public static DateTime DateTime { get; set; }
= new DateTime(2023, 4, 5, 6, 7, 8, DateTimeKind.Unspecified);

public static TimeSpan Time { get; set; } = new TimeSpan(1, 12, 30, 45);
public static DateTime DateTimeLocal { get; }
= new DateTime(2023, 4, 5, 6, 7, 8, DateTimeKind.Local);

public static DateTime DateTimeUtc { get; }
= new DateTime(2023, 4, 5, 6, 7, 8, DateTimeKind.Utc);

public static TimeSpan TimeSpan { get; set; } = new TimeSpan(1, 12, 30, 45);

public static DateTimeOffset DateTimeOffset { get; set; }
= new DateTimeOffset(DateTime, -TimeSpan.FromMinutes(90));

public static KeyValuePair<string, int> Pair { get; set; }
= new KeyValuePair<string, int>("pair", 1);
Expand Down
Loading
Loading