Skip to content

Replace Newtonsoft.Json with System.Text.Json (byte-equal wire format)#393

Open
ajwillshire wants to merge 18 commits into
Zaid-Ajaj:masterfrom
ajwillshire:stj-json-converter-port
Open

Replace Newtonsoft.Json with System.Text.Json (byte-equal wire format)#393
ajwillshire wants to merge 18 commits into
Zaid-Ajaj:masterfrom
ajwillshire:stj-json-converter-port

Conversation

@ajwillshire
Copy link
Copy Markdown

Hi Zaid,

I had a couple of errors that arose from mixed use of Newtonsoft and
System.Text.Json in an apllication I was building so I asked Claude Code to write an update for
Fable.Remoting that would allow you to remove Newtonsoft from
Fable.Remoting altogether. Details are below — hope this is useful.

Cheers,
Andrew


TL;DR

This PR ports the JSON serializer from Newtonsoft.Json to
System.Text.Json, byte-equally for every shape Fable.Remoting
produces today. STJ becomes the default; Newtonsoft is kept as an
explicit [<Obsolete>] opt-in for one major version, then deletable in
v5.0.

  • Parallel STJ converter set in Fable.Remoting.Json.
  • Backend choice plumbed through Fable.Remoting.Server, all six
    sibling adapters (Giraffe / Suave / Falco / AspNetCore / AwsLambda × 2
    / AzureFunctions.Worker), and Fable.Remoting.DotnetClient.
  • Remoting.createApi() now defaults to STJ. Wire format is byte-equal
    to the previous Newtonsoft default — verified by 349 byte-pin tests
    running the same assertions against both serializers, plus 70+ HTTP
    integration tests covering Giraffe / Suave / Falco round-tripping
    representative shapes through real TestServers wired to both
    backends.
  • FableJsonConverter and a new Remoting.withNewtonsoftJson
    opt-back-in helper are [<Obsolete>] with migration pointers.
  • v5.0 follow-up is pure deletion — no design decisions left. See
    MIGRATION.md for the timeline.

Two pre-existing Newtonsoft bugs were caught and fixed as a side-effect:
Map<K, V> deserialise from "null" crashed; [<Fable.Core.Pojo>] and
[<Fable.Core.StringEnum>] were silently ignored on DUs with
field-bearing cases. Details below — happy to split those into a
precursor PR for v4.x consumers if you'd prefer.

// Existing consumers — no change required (wire format byte-equal).
Remoting.createApi()
|> Remoting.fromValue myImpl
|> Remoting.buildHttpHandler

// Pin to the legacy Newtonsoft path during migration (deprecation warning).
Remoting.createApi()
|> Remoting.withNewtonsoftJson    // [<Obsolete>]
|> Remoting.fromValue myImpl
|> Remoting.buildHttpHandler

Motivation

Newtonsoft.Json is in maintenance mode; System.Text.Json ships in the
BCL on net8.0 (no new package reference needed). Every Fable.Remoting.*
consumer pulls Newtonsoft transitively today — projects going OSS-public,
running supply-chain audits, or just trying to minimise their dependency
graph inherit it without a way to opt out. This PR delivers the opt-out,
and lays the foundation for v5.0 to drop the Newtonsoft package reference
from Fable.Remoting.Json entirely.

The client side (Fable.SimpleJson) is already Newtonsoft-free, so the
entire PR is server-side.

What landed

Fable.Remoting.Json — parallel STJ converter set

New file Fable.Remoting.Json/FableSystemTextJsonConverter.fs
(~1000 lines). Every Kind branch the existing Newtonsoft
FableJsonConverter handles has a matching System.Text.Json converter:

Newtonsoft Kind STJ converter
Kind.Union FSharpUnionConverter<'T> + factory
Kind.PojoDU FSharpPojoDUConverter<'T> + factory
Kind.StringEnum FSharpStringEnumConverter<'T> + factory
Kind.Option FSharpOptionConverter<'T> + factory
Kind.Tuple FSharpTupleConverter<'T> + factory
Kind.Other (plain records) FSharpRecordConverter<'T> + factory
Kind.MutableRecord FSharpCliMutableRecordConverter<'T> + factory
Kind.MapWithStringKey FSharpMapStringKeyConverter<'V> + factory
Kind.MapOrDictWithNonStringKey FSharpMapNonStringKeyConverter<'K,'V> + factory
Kind.Long (int64) Int64Converter
Kind.Long (uint64) UInt64Converter
Kind.BigInt BigIntConverter
Kind.DateTime DateTimeConverter (three-way Kind branching preserved)
Kind.TimeSpan TimeSpanConverter
Kind.DateOnly DateOnlyConverter
Kind.TimeOnly TimeOnlyConverter
Kind.DataTable / Kind.DataSet DataTableConverter / DataSetConverter
Kind.Other (sets) FSharpSetConverter<'T> + factory
Kind.Other (lists) FSharpListConverter<'T> + factory

Plus two converters that don't have a direct Kind analogue — both are
byte-compat workarounds for places STJ defaults diverge from Newtonsoft:

  • DoubleConverter — STJ's WriteNumberValue(0.0) emits "0";
    Newtonsoft emits "0.0". Converter restores the trailing .0 for
    whole-valued doubles using ToString("R") + appended ".0" when no
    decimal/exponent is present.
  • StringConverter — Newtonsoft emits high-codepoint codepoints (emoji
    etc.) as raw UTF-8 bytes. JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    in STJ still escapes them to \uXXXX\uXXXX surrogate-pair escapes in
    practice (verified empirically). The converter uses
    Utf8JsonWriter.WriteRawValue to bypass STJ's encoder entirely and
    emits only the RFC-8259-required escapes (", \, control chars).

A FableConverters module exposes the conventional registration helpers:

module FableConverters =
    val addTo : JsonSerializerOptions -> unit
    val create : unit -> JsonSerializerOptions

addTo validates that the options instance isn't already in use (STJ
freezes options after first serialize call). create returns a fresh,
fully-configured instance.

The FSharpUnionConverter reader handles all five input shapes the
Newtonsoft path supports (per FableConverter.fs:594-659):

  1. JsonTokenType.Null → default-of-T.
  2. String → no-field case by name.
  3. StartObject with __typename (union-of-records, case-insensitive).
  4. StartObject with {tag, name, fields} (Fable runtime form).
  5. StartObject single-property (writer round-trip — {"<Case>": value-or-array}).
  6. StartArray (["<Case>", <f>, ...]).

Fable.Remoting.Server — opt-in plumbing + default flip

  • Types.fs — new JsonSerializerBackend DU:

    type JsonSerializerBackend =
        | NewtonsoftJson
        | SystemTextJson of System.Text.Json.JsonSerializerOptions

    Plus a new JsonSerializer field on RemotingOptions<'context, 'serverImpl>
    and on the internal MakeEndpointProps record. InvocationPropsInt.Arguments
    changed from Choice<byte[], JToken> list to Choice<byte[], string> list
    — the raw JSON text of each argument is backend-agnostic, so the STJ
    path doesn't touch JToken at runtime.

  • Remoting.fscreateApi() defaults to SystemTextJson with a
    cached module-level defaultStjOptions (one FableConverters.create()
    instance reused across all createApi() calls); new
    Remoting.withNewtonsoftJson [<Obsolete>] fluent helper for the
    legacy opt-in; existing Remoting.withSerializerOptions accepts a
    custom JsonSerializerOptions.

  • Proxy.fsjsonSerializeWithBackend (public, so adapters can
    consume), parseArgumentArray (outer-array slicing branched on
    backend), deserialiseArgWithBackend (per-argument deserialise
    branched on backend). The Newtonsoft per-arg path uses a dedicated
    fableArgSerializer with DateParseHandling.None and
    DateTimeZoneHandling.RoundtripKind to preserve as much of the
    original JToken-roundtrip semantics as possible. One known
    limitation: DateTimeOffset offset preservation through the Newtonsoft
    path is now fragile (see "Known follow-ups" below); the STJ default
    path preserves offsets correctly.

Sibling adapters — setBody helpers backend-aware

Six adapters had a parallel setBody-shape helper (setJsonBody /
setResponseBody / etc.) that called the Newtonsoft jsonSerialize
directly, bypassing the backend choice for error responses and the
docs schema OPTIONS /$schema endpoint. Each now takes a
JsonSerializerBackend parameter routed from options.JsonSerializer:

  • Fable.Remoting.Giraffe/FableGiraffeAdapter.fs
  • Fable.Remoting.Suave/FableSuaveAdapter.fs
  • Fable.Remoting.Falco/FableFalcoAdapter.fs
  • Fable.Remoting.AspNetCore/Middleware.fs
  • Fable.Remoting.AwsLambda/FableLambdaAdapter.fs
  • Fable.Remoting.AwsLambda/FableLambdaApiGatewayAdapter.fs
  • Fable.Remoting.AzureFunctions.Worker/FableAzureFunctionsAdapter.fs

Fable.Remoting.DotnetClient — parallel opt-in surface

  • Proxy<'t>.WithSerializerOptions(opts: JsonSerializerOptions) : Proxy<'t>
    — builder member on the constructor-style API.
  • Remoting.withSerializerOptions opts options — fluent helper on the
    Remoting.createApi → buildProxy path.
  • RemoteBuilderOptions gains a StjOptions: JsonSerializerOptions option
    field. 14 internal ServiceCallerFuncN types thread stjOptions
    through their constructors and into Proxy.proxyPost/proxyPostTask
    calls.

Deprecation surface

  • [<Obsolete>] on Fable.Remoting.Json.FableJsonConverter (the legacy
    converter class).
  • [<Obsolete>] on Fable.Remoting.Server.Remoting.withNewtonsoftJson.
  • Internal usages of FableJsonConverter (the implementations of the
    legacy path that remain supported through the deprecation window) are
    guarded with #nowarn "44" in Server.Proxy, Server.Documentation,
    DotnetClient.Proxy. Test files that intentionally exercise the
    legacy path are similarly annotated with a header comment explaining
    why.

Pre-existing Newtonsoft bugs caught + fixed

Surfaced by the byte-equality testing, since both bugs broke byte-compat
in ways the existing test suite never exercised:

1. Map<K, V> deserialise from "null" crashes with
InvalidCastException at FableConverter.fs:669 — the
Kind.MapWithStringKey else-branch (array-of-pairs fallback) read
serializer.Deserialize<JToken>(reader) :?> JArray without a
JsonToken.Null guard. Bites any Fable client that ever sends null
for an Option<Map<...>> field. The STJ path doesn't share the bug
(STJ's default HandleNull = false returns null directly without
invoking the converter). Documented with an STJ-only test list in
StjWireFormatTests.fs.

2. [<Fable.Core.Pojo>] / [<Fable.Core.StringEnum>] silently ignored on
DUs with field-bearing cases.
getUnionKind at
FableConverter.fs:156-163 read attributes from the runtime
case-subtype (e.g. PojoDU+PojoOne), which doesn't inherit the
attribute from the declaring DU. Fixed by normalising to the declaring
type first. The STJ path was correct by construction — factories
dispatch on the declared static type.

Happy to split these into a small precursor PR if you want v4.x
consumers to benefit immediately without taking the whole STJ port.

Tests

Fable.Remoting.Json.Tests (349 total):

  • WireFormatTests.fs — 103 byte-equality tests + 26 dedicated
    null-handling tests + 12 Pojo/StringEnum byte-pin tests. The gallery
    is parameterised by an ISerializer abstraction; the same tests run
    against both serializers byte-for-byte.
  • StjWireFormatTests.fs — STJ instantiation of the gallery.
  • StjUnionPrototypeTests.fs — 23 STJ-specific reader tests covering
    the five input shapes.

Fable.Remoting.Giraffe.Tests / Suave.Tests / Falco.Tests each gain
TWO new files:

  • StjHttpIntegrationTests.fs — STJ HTTP integration tests through real
    TestServer (Giraffe / Falco) or a live Suave listener.
  • LegacyNewtonsoftIntegrationTests.fs — same shape, pinned to
    withNewtonsoftJson. Keeps the legacy path under automated coverage
    through the deprecation window. When v5.0 deletes the Newtonsoft
    branch, this is the file in each adapter that retires alongside.

Test totals on this branch:

Project Count
Fable.Remoting.Json.Tests 349 (50 pre-existing + 299 new)
Fable.Remoting.Server.Tests 30 (unchanged)
Fable.Remoting.MsgPack.Tests 55 (unchanged)
Fable.Remoting.Suave.Tests 48 (28 legacy + 13 STJ + 7 legacy-canary)
Fable.Remoting.Giraffe.Tests 120 (96 legacy + 18 STJ + 6 legacy-canary)
Fable.Remoting.Falco.Tests 102 (77 legacy + 18 STJ + 7 legacy-canary)
Total 704 ✅

Migration story for consumers

See MIGRATION.md at the repo root for the full guide.
The TL;DR:

  1. Most consumers do nothing. Wire format is byte-equal; existing
    Fable / DotnetClient clients receive the same bytes.
  2. Consumers cautious about the flip can pin to the legacy path
    during their migration window via |> Remoting.withNewtonsoftJson
    (deprecation warning fires).
  3. Consumers who reference FableJsonConverter directly (e.g. for
    custom SSE serialisation) swap to FableConverters.create() from the
    Fable.Remoting.Json.SystemTextJson namespace.

MIGRATION.md includes a security note on
UnsafeRelaxedJsonEscaping's non-escaping of HTML-sensitive characters
— same behaviour as the previous Newtonsoft default, but worth flagging
to anyone re-auditing their serialiser setup.

Known follow-ups (deliberately out of scope)

  • DateTimeOffset offset preservation on the legacy Newtonsoft path.
    Writing the legacy-canary tests surfaced that
    Maybe<DateTimeOffset> round-trips through withNewtonsoftJson lose
    the original offset (rewritten to the server's local TZ). The STJ
    path doesn't share the bug. Root cause appears to be in
    FableJsonConverter's Kind.Union single-field-case branch — the
    inner JTokenReader.CreateReader() doesn't fully inherit
    DateParseHandling.None from the outer serializer. Documented in
    MIGRATION.md as a "migrate to STJ if you depend on this" item. v5.0's
    deletion of the Newtonsoft path makes the limitation moot.
  • Per-adapter HTTP integration tests for AspNetCore / AwsLambda /
    AzureFunctions.Worker.
    Adapter code is plumbed; shape is identical
    to Giraffe / Suave / Falco. Existing tests cover the shape by proxy.
    AzureFunctions specifically needs a CI-friendly replacement for the
    manual-FunctionApp-on-localhost rig.
  • v5.0 deletion sweep. When you flip the major version, this PR's
    contents enable deleting Fable.Remoting.Json/FableConverter.fs,
    dropping Newtonsoft.Json from Fable.Remoting.Json/paket.references,
    removing the NewtonsoftJson case from JsonSerializerBackend,
    removing Remoting.withNewtonsoftJson, and retiring every
    LegacyNewtonsoftIntegrationTests.fs plus the pre-existing
    Newtonsoft-only adapter test files. Pure deletion, no design
    decisions.

Documentation in the PR

  • MIGRATION.md — consumer-facing migration guide
    (~350 lines). TL;DR for typical consumers, three migration paths by
    profile, security note on the encoder's HTML-sensitive-character
    behaviour, timeline for v4 → v5 retirement.
  • BYTE-COMPAT-MAP.md — internal working artefact
    documenting every Kind branch's wire shape, surprises caught during
    the port, design rationale. ~1700 lines, written during the work as
    both a navigation map and an empirical-findings log. Useful as a
    reference for whoever maintains the converter set going forward, but
    verbose. Happy to trim or drop from the PR if you'd prefer a leaner
    change.

Testing locally

# Byte-compat matrix (349 tests, both serializers in parallel)
dotnet run --project Fable.Remoting.Json.Tests/Fable.Remoting.Json.Tests.fsproj

# Full HTTP integration tests for Giraffe / Suave / Falco
dotnet run --project Fable.Remoting.Giraffe.Tests/Fable.Remoting.Giraffe.Tests.fsproj
dotnet run --project Fable.Remoting.Suave.Tests/Fable.Remoting.Suave.Tests.fsproj
dotnet run --project Fable.Remoting.Falco.Tests/Fable.Remoting.Falco.Tests.fsproj

# Or run any other Tests project the same way.

The suites are Expecto console runners (<OutputType>Exe</OutputType>);
use dotnet run, not dotnet test (which silently exits zero — known
upstream pattern).

Diff stats

~38 files changed, ~5000 insertions(+), ~200 deletions(-)

Most of the insertions are documentation (BYTE-COMPAT-MAP.md +
MIGRATION.md) and the test gallery (parallel STJ runs of the byte-pin
gallery, plus the legacy-canary HTTP integration tests). The actual
converter code is ~1100 lines; sibling-adapter plumbing is single-line
changes per helper. The FableConverter.fs (Newtonsoft path) is
untouched except for two bug fixes (getUnionKind normalisation +
documenting the [<Obsolete>] rationale) and the deprecation
annotation.

Read-only walk of Fable.Remoting.Json (FableConverter.fs, 18 Kind branches),
public surface, known consumers (Server/Proxy.fs, DotnetClient/Proxy.fs,
benchmarks), existing test coverage, TFM posture (net8.0 only post-PR#391),
and six open questions the operator needs to resolve before Phase 2 byte
tests are authored.

No source changes. First deliverable on the stj-json-converter-port branch.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Adds WireFormatTests.fs with byte-equality assertions against the current
Newtonsoft converter output. Covers primitives, longs/bigints, options,
lists/arrays, tuples, records (including non-alphabetical field order),
discriminated unions (no-field/single/multi/recursive/generic/record-typed),
maps (string-key, int-key, Guid-key, no-field-DU-key, tuple-key), sets,
DateTime (Utc/Unspecified/Local), TimeSpan, DateTimeOffset, and 6 type
combinations. 153/153 pass (50 pre-existing + 103 new).

Run via dotnet run (not dotnet test — Expecto console runner shape).

Two wire-format surprises found and logged in BYTE-COMPAT-MAP.md §10:

1. Newtonsoft emits raw UTF-8 for high-codepoint characters (e.g. emoji),
   not \uXXXX escape pairs. STJ defaults to escaping non-ASCII; the port
   must register against JsonSerializerOptions with Encoder set to
   JavaScriptEncoder.UnsafeRelaxedJsonEscaping. Non-negotiable for
   byte-compat.

2. DateTimeKind.Unspecified is NOT silently promoted to UTC — the writer
   only calls .ToUniversalTime() on DateTimeKind.Local, and "O" format
   then emits no zone suffix for Unspecified kinds. Source comment at
   FableConverter.fs:408-410 is misleading; it refers to deserialise
   behaviour, not the wire output.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Adds Fable.Remoting.Json/FableSystemTextJsonConverter.fs containing:

  * FSharpUnionConverter<'T>      — per-type typed JsonConverter
  * FSharpUnionConverterFactory   — JsonConverterFactory dispatching to it
  * UnionReflection module        — pre-computed UnionInfo (tag reader,
                                    cases, FieldTypes, FieldReader,
                                    Constructor) cached per union type

Writer produces byte-identical output to FableJsonConverter (Newtonsoft):
  - No-field case  : "<CaseName>"
  - 1-field case   : {"<CaseName>": <field>}
  - N-field case   : {"<CaseName>": [<f1>, ..., <fN>]}

Multi-field path serialises each field with its declared FieldType via
JsonSerializer.Serialize(writer, value, type, options) — NOT as obj[] — to
avoid STJ routing through the obj converter (would emit type discriminators
or fail). The case.FieldTypes from FSharpType.GetUnionCases gives the
static-typed dispatch information STJ needs.

Reader implements the writer-roundtrippable subset: Null token, String token
(no-field), and single-property StartObject (1-field and N-field). The four
additional input shapes Newtonsoft accepts (__typename / {tag,name,fields}
Fable-runtime / ["<Case>", <f>, ...] array / case-insensitive __typename)
are deferred to Phase 4 — they're read-only and don't affect the
writer-side wire contract.

23 new tests in Fable.Remoting.Json.Tests/StjUnionPrototypeTests.fs:
  13 writer (byte-equal to Phase 2 Newtonsoft pins)
  10 reader (round-trips writer output)
176/176 total tests pass (50 + 103 + 23).

Factory-vs-dispatch choice + Phase 3 design notes captured in
BYTE-COMPAT-MAP.md §11.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Adds the complete System.Text.Json converter inventory to
Fable.Remoting.Json/FableSystemTextJsonConverter.fs, plus a parallel test run
that puts every Phase 2 byte-pin through both serializers. 279/279 pass —
the wire format is now byte-equal between Newtonsoft and STJ.

Converters added in this commit:
  * FSharpOptionConverter<'T> + factory
  * FSharpTupleConverter<'T> + factory
  * FSharpRecordConverter<'T> + factory
  * FSharpCliMutableRecordConverter<'T> + factory ([<CLIMutable>] records,
    properties via Type.GetProperties order, null-valued props omitted)
  * FSharpSetConverter<'T> + factory (sorted JSON array)
  * FSharpListConverter<'T> + factory (explicit, avoids relying on STJ's
    IEnumerable default)
  * FSharpMapStringKeyConverter<'V> + factory
  * FSharpMapNonStringKeyConverter<'K,'V> + factory (serialised key →
    property name, escaped-quotes pattern matches Newtonsoft exactly)
  * Int64Converter / UInt64Converter / BigIntConverter (string-form)
  * DateTimeConverter (Local→UTC→Z; Utc→Z; Unspecified→no zone)
  * TimeSpanConverter (TotalMilliseconds with Newtonsoft-style decimal)
  * DateOnlyConverter / TimeOnlyConverter
  * DataTableConverter / DataSetConverter (schema+data XML wrapping)

Plus, for byte-equality with Newtonsoft:
  * DoubleConverter — STJ's WriteNumberValue(0.0) writes "0"; Newtonsoft
    writes "0.0". The converter uses ToString("R") + ".0" suffix when the
    formatted string has no decimal/exponent marker. The same helper is
    used by TimeSpanConverter.
  * StringConverter — STJ's UnsafeRelaxedJsonEscaping still escapes
    supplementary-plane codepoints (emoji etc.) to \uXXXX\uXXXX pairs;
    Newtonsoft passes them through as raw UTF-8. The converter does its
    own RFC-8259-required escaping (", \, control chars) and uses
    WriteRawValue to bypass STJ's encoder entirely.

FSharpUnionConverter reader extended (Phase 3 was writer-roundtrip subset
only) to accept all five input shapes the Newtonsoft path supports:
  - Null token
  - String (no-field case by name)
  - StartObject with {tag, name, fields} (Fable runtime)
  - StartObject with __typename (union-of-records, case-insensitive)
  - StartObject single-property (writer roundtrip)
  - StartArray ["<Case>", <f>, ...]

Test infrastructure refactor: WireFormatTests.fs now exposes
buildWireFormatTests parameterised by ISerializer, so the 103-test gallery
runs against both Newtonsoft (Phase 2) and STJ (Phase 4) from a single
source of truth. StjWireFormatTests.fs instantiates the gallery against
FableConverters.create().

Opt-in surface for downstream consumers: FableConverters.create() and
FableConverters.addTo(options). Newtonsoft remains the default — no
changes to FableConverter.fs (Phase 4 lives strictly alongside).

Pojo/StringEnum DU dispatch deferred — no test fixtures exist and no
client-emitted output to byte-match. Documented in BYTE-COMPAT-MAP §12.6
for a follow-up PR.

Findings + design notes captured in BYTE-COMPAT-MAP.md §12.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Verification deliverables:

- 565/565 pre-existing tests pass across Fable.Remoting.Json (279),
  Fable.Remoting.Server (30), Fable.Remoting.MsgPack (55),
  Fable.Remoting.Suave (28), Fable.Remoting.Giraffe (96),
  Fable.Remoting.Falco (77). The Suave/Giraffe/Falco suites exercise
  the Newtonsoft serialiser through full HTTP serialise → wire →
  deserialise → assert cycles, so passing here is strong evidence
  the existing wire path is unbroken.

- 279/279 byte-pin matrix (Newtonsoft + STJ) pass byte-equally —
  delivered in Phase 4, re-confirmed on this branch tip.

- dotnet pack produces a clean Fable.Remoting.Json.3.0.0.nupkg
  (89.6 KB net8.0 DLL, no new transitive deps; System.Text.Json
  pulled from BCL on net8.0 not as a separate package reference).

Forge end-to-end HelloWorld spot-check ADAPTED — the brief's literal
request isn't possible against the current state of toolup-forge:

- samples/HelloWorld/ is incomplete in toolup-forge today: only
  HelloWorld.Module/ is authored, Server + Client composition roots
  are listed as "not yet authored" in the sample's README.
- STJ opt-in at HelloWorld level would require modifying
  Fable.Remoting.Server/Proxy.fs (explicit out-of-scope per the
  task brief — that's downstream-plumbing PR territory).

In lieu, the strongest in-scope evidence:
  1. The byte-equality matrix (STJ writes ≡ Newtonsoft writes for
     103 representative shapes including all five Fable-client
     reader input forms) — this IS the deserialisation contract.
  2. HTTP integration roundtrips through Suave/Giraffe/Falco (201
     tests) prove the Newtonsoft path is intact post-Phase-4.
  3. Downstream plumbing sketch documented for the maintainer.

Documented in BYTE-COMPAT-MAP.md §13.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Widens scope to make the PR self-contained: instead of shipping
FableConverters.create() as opt-in surface that nobody can opt into without
a follow-up PR, this commit plumbs the choice through
Fable.Remoting.Server. One-line consumer opt-in:

    Remoting.createApi()
    |> Remoting.fromValue myImpl
    |> Remoting.withSerializerOptions (FableConverters.create())
    |> Remoting.buildHttpHandler

Default is unchanged (Newtonsoft). Existing consumers see no behaviour
difference. The widening is minimal:

  * Fable.Remoting.Server/Types.fs:
    - new JsonSerializerBackend DU (NewtonsoftJson | SystemTextJson opts)
    - JsonSerializer field on RemotingOptions<'context, 'serverImpl>
    - JsonSerializer field on internal MakeEndpointProps
  * Fable.Remoting.Server/Remoting.fs:
    - createApi() defaults JsonSerializer = NewtonsoftJson
    - new Remoting.withSerializerOptions fluent helper
  * Fable.Remoting.Server/Proxy.fs:
    - new jsonSerializeWithBackend that branches output serialisation
    - branched per-argument deserialisation in makeEndpointProxy

The outer JSON-array parsing (slicing [arg1, arg2, ...] into separate
JTokens) still goes through Newtonsoft — generalising
InvocationPropsInt.Arguments away from Choice<byte[], JToken> would be a
much bigger refactor and isn't on the byte-compat hot path. Per-argument
and response shapes are STJ-routed when opted in.

New HTTP integration test file:
  Fable.Remoting.Giraffe.Tests/StjHttpIntegrationTests.fs

18 end-to-end tests covering primitives (int/string/bool), Option, Record
(including None field), DU (Maybe<int>, AB), lists, Maps (string-key and
tuple-key), bigint, Result<int,string>, and binary byte[] round-trips.
Spins up a parallel TestServer wired with FableConverters.create() and
exercises each through full HTTP serialise → wire → deserialise → assert
cycles.

Test totals after Phase 4b:
  297 Fable.Remoting.Json.Tests        (50 + 103 Phase 2 + 23 Phase 3 + 103 Phase 4 + 18 STJ HTTP)
   30 Fable.Remoting.Server.Tests
   55 Fable.Remoting.MsgPack.Tests
   28 Fable.Remoting.Suave.Tests
  114 Fable.Remoting.Giraffe.Tests     (96 existing + 18 new STJ HTTP)
   77 Fable.Remoting.Falco.Tests
  ---
  583/583 pass

The Giraffe STJ tests deliver what Phase 6's HelloWorld spot-check
couldn't: a real consumer-shaped HTTP server serves STJ-serialised JSON,
and the wire-format contract holds under load.

Sibling adapters (Suave / Falco / AzureFunctions / DotnetClient) keep
their Newtonsoft default unchanged. Adding the same opt-in helper to each
is a follow-up PR's territory — the converter package's public surface
(FableConverters.create()) is all those follow-ups would need.

Documented in BYTE-COMPAT-MAP.md §14.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
…t bug

Adds focused null-handling tests at the operator's request. This work was
prompted by Fable's F# 10 nullable-reference-types rollout, so verifying
the converter chain handles nulls correctly is load-bearing for the PR.

52 new tests across both serializers (26 cases × 2 — both byte-equal and
behaviour-equal):

Serialise side (16 cases):
  - Top-level nulls: string, string[], int[]
  - Nullable<int> with value / empty
  - Some null (string) → collapses to JSON null
  - Records with null reference fields / all reference fields null
  - Records with None / Some null option fields
  - Collections containing null elements (string list/array, Map<string,_>)
  - Tuples with null elements
  - DU fields wrapping null strings (Wrapped null, Two(5, null))

Deserialise side (10 cases):
  - JSON "null" → null for string, string[], int list, Set<int>
  - JSON "null" → null reference for record and DU (Unchecked.defaultof<T>)
  - JSON "null" → None for option
  - JSON "null" → empty Nullable<int>
  - Object with null field → record with null reference / None option
  - Array with null elements → list with nulls preserved
  - Object with null value → Map<string,string> with null value preserved

ISerializer extended with Deserialize<'a> so the same parameterised gallery
covers both serialise + deserialise across both serializers.

SURFACED PRE-EXISTING NEWTONSOFT BUG:

  JsonConvert.DeserializeObject<Map<string,int>>("null", FableJsonConverter())

crashes with InvalidCastException at FableConverter.fs:669. The
Kind.MapWithStringKey else-branch (array-of-pairs fallback) reads
`serializer.Deserialize<JToken>(reader) :?> JArray` without a JsonToken.Null
guard. JValue null can't cast to JArray → crash.

The STJ port doesn't share the bug: FSharpMapStringKeyConverter is a
JsonConverter<Map<string,V>>, STJ's default HandleNull=false returns null
directly for null tokens without invoking the converter.

Added a STJ-only test list stjFixesNewtonsoftNullBug documenting the fix
(Map<string,int> and Map<Color,int> both deserialise from "null" cleanly).
The PR description will flag this as an unintentional improvement — the
bug affects Fable clients that serialise `None` for `Option<Map<...>>`
fields, which is presumably why it's gone unreported.

Test matrix after Phase 4c:
   337 Fable.Remoting.Json.Tests
    30 Fable.Remoting.Server.Tests
    55 Fable.Remoting.MsgPack.Tests
    28 Fable.Remoting.Suave.Tests
   114 Fable.Remoting.Giraffe.Tests
    77 Fable.Remoting.Falco.Tests
  ---
  641/641 pass

Documented in BYTE-COMPAT-MAP.md §15.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Drafts to use when opening upstream conversation with Zaid-Ajaj. Per task
brief these are DRAFTS for the operator to review and post; neither file
is published from this commit.

UPSTREAM-ISSUE-DRAFT.md frames the proposal as a "before I open a PR"
sign-off request:

  * Motivation: drop transitive Newtonsoft dep
  * Approach: parallel STJ converter set; opt-in via new field on
    RemotingOptions; Newtonsoft remains the default
  * Byte-compat guarantee with reference to the 641-test matrix
  * Suggested PR shape: one PR or three-PR stack — operator's choice
  * Surfaces the pre-existing Newtonsoft Map<K,V> null bug as an
    unintentional improvement
  * Lists deferred follow-ups (sibling adapter response paths,
    DotnetClient opt-in helper, Pojo/StringEnum DU dispatch,
    outer-array argument parsing generalisation)
  * Asks five concrete sign-off questions (approach acceptable? PR
    shape? Newtonsoft bug fix in same PR? Naming preferences?
    BYTE-COMPAT-MAP.md disposition?)

UPSTREAM-PR-DRAFT.md is the PR description framed as "here's what
landed":

  * Summary + before/after snippet
  * Kind-to-converter mapping table (every branch covered)
  * Two extra converters (DoubleConverter for "0" vs "0.0";
    StringConverter for emoji passthrough via WriteRawValue)
  * Server-side plumbing (JsonSerializerBackend DU, withSerializerOptions
    helper, branched Proxy.fs)
  * Test totals: 641/641
  * Diff stats: 14 files, 2820 insertions, 17 deletions
  * Migration story for consumers (one-line opt-in)
  * Same unintentional-improvement and out-of-scope sections
  * Local testing commands

Neither file is opened upstream from this commit — the operator owns
the social side per the task brief.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Plumbs the JsonSerializerBackend opt-in through every adapter that bypassed
the Server.Proxy.makeApiProxy path with a hardcoded Newtonsoft jsonSerialize
call. Plus a parallel opt-in surface on Fable.Remoting.DotnetClient.

Fable.Remoting.Server:
  * jsonSerializeWithBackend changed from private to public so adapters
    can route their response-path helpers through it.

Six sibling adapters — Newtonsoft default unchanged, STJ opt-in plumbed:
  * Fable.Remoting.Giraffe   — setJsonBody + fail backend-aware
  * Fable.Remoting.Suave     — setResponseBody + success + sendError + fail
  * Fable.Remoting.Falco     — setResponseBody + setBody + fail
  * Fable.Remoting.AspNetCore — setResponseBody + setBody + fail
  * Fable.Remoting.AwsLambda (Lambda + ApiGateway adapters)
  * Fable.Remoting.AzureFunctions.Worker

Pattern: each setBody-shape helper grew a JsonSerializerBackend parameter;
each `fail` entry pulls options.JsonSerializer once and threads it down.
Error responses and docs-schema responses now respect the opt-in.

Fable.Remoting.DotnetClient — new opt-in surface:
  * Proxy<'t>.WithSerializerOptions(opts) — builder member returning a
    proxy configured for STJ
  * Remoting.withSerializerOptions — fluent helper on the
    Remoting.createApi → buildProxy path
  * RemoteBuilderOptions gains a StjOptions field (defaults to None)
  * Every ServiceCallerFuncN type (14 total) threads stjOptions through
    its constructor and Proxy.proxyPost/proxyPostTask calls
  * buildProxy reflectively passes StjOptions to Activator.CreateInstance

HTTP integration tests:
  * Fable.Remoting.Suave.Tests/StjHttpIntegrationTests.fs — 13 tests
  * Fable.Remoting.Falco.Tests/StjHttpIntegrationTests.fs — 18 tests

The Falco STJ tests exercise BOTH ends of the wire in one round-trip:
Falco server with `Remoting.withSerializerOptions stjOptions` (server STJ);
DotnetClient.Proxy.custom with `.WithSerializerOptions(stjOptions)`
(client STJ). Dogfoods the full Phase 4d plumbing.

Test matrix after Phase 4d:
   337  Fable.Remoting.Json.Tests
    30  Fable.Remoting.Server.Tests
    55  Fable.Remoting.MsgPack.Tests
    41  Fable.Remoting.Suave.Tests       (28 pre-existing + 13 new STJ HTTP)
   114  Fable.Remoting.Giraffe.Tests     (96 pre-existing + 18 new STJ HTTP)
    95  Fable.Remoting.Falco.Tests       (77 pre-existing + 18 new STJ HTTP)
  ---
   672/672 pass

Documented in BYTE-COMPAT-MAP.md §16.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
UPSTREAM-ISSUE-DRAFT.md:
  * Reframes adapter scope — all 6 sibling adapters AND DotnetClient are
    now plumbed (was: "PR Zaid-Ajaj#3 deferred").
  * New test totals: 672/672 (was: 641/641).
  * Three-PR stack reshape — PR Zaid-Ajaj#2 absorbs the sibling adapter cleanup,
    PR Zaid-Ajaj#3 is just DotnetClient.
  * Falco STJ tests flagged as load-bearing — they exercise both ends of
    the wire (Falco server STJ + DotnetClient.Proxy STJ) in one round-trip.
  * Known-follow-ups trimmed: DotnetClient and sibling-adapter cleanup
    removed (both now in the PR); only Pojo/StringEnum dispatch, outer-
    array parsing, and belt-and-braces tests for AspNetCore / AwsLambda /
    AzureFunctions remain deferred.

UPSTREAM-PR-DRAFT.md:
  * Adds "Sibling adapters" section listing all six setBody-shape helpers.
  * Adds "Fable.Remoting.DotnetClient" section explaining the dual opt-in
    surface (Proxy<'t>.WithSerializerOptions builder + Remoting.withSerializerOptions
    fluent helper) and the threading through 14 ServiceCallerFuncN types.
  * Updated test totals: 672/672.
  * Updated diff stats: 31 files, +4030, -163.
  * Same out-of-scope trim — DotnetClient + sibling adapters removed
    from deferred list.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
…wtonsoft bug

Adds two STJ converters covering the remaining DU dispatch paths:

  FSharpPojoDUConverter<'T>      — emits {"type":"<Case>","<F1>":<v1>,...}
                                   for unions tagged with [<Fable.Core.Pojo>]
  FSharpStringEnumConverter<'T>  — emits "<lowercaseFirst>" or [<CompiledName>]
                                   override for unions tagged with
                                   [<Fable.Core.StringEnum>]

Both factories are registered in FableConverters.addTo BEFORE the regular
FSharpUnionConverterFactory; the regular factory's CanConvert was updated
to explicitly exclude attribute-tagged DUs.

Test fixtures:
  * Fable.Remoting.Json.Tests/FableCoreShim.fs — shim Fable.Core.Pojo /
    StringEnum attributes (the converters detect by FullName, no Fable.Core
    paket dep needed).
  * WireFormatTests: 6 new byte-pin tests (3 Pojo + 3 StringEnum) running
    through both Newtonsoft and STJ.

SURFACED ANOTHER PRE-EXISTING NEWTONSOFT BUG (and fixed it):

  FableConverter.fs `getUnionKind` read `[<Fable.Core.Pojo>]` from the
  RUNTIME type via `value.GetType()`. For a DU with at least one
  field-bearing case, F# emits each case as a nested subtype
  (e.g. `PojoDU+PojoOne`), and these subtypes do NOT inherit the
  [<Pojo>] / [<StringEnum>] attribute from the declaring DU. So the
  attribute lookup silently returned None → fallback to Kind.Union →
  Pojo DUs were mis-serialised as regular Unions.

  Fixed by normalising to the declaring type via
  FSharpType.GetUnionCases(t, bindingFlags).[0].DeclaringType before
  reading custom attributes.

  The STJ path was correct by construction — factories dispatch on the
  declared static type, not the runtime case-subtype.

Test totals after Phase 4e:
   349  Fable.Remoting.Json.Tests   (was 337, +6 Pojo, +6 StringEnum)

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
…ops Newtonsoft at runtime)

`InvocationPropsInt.Arguments` was Choice<byte[], JToken> list — a
Newtonsoft.Json.Linq.JToken in the type signature. Even with STJ opted in,
the outer JSON-array parsing of [arg1, arg2, ...] was hardcoded to
JsonConvert.DeserializeObject<JToken>, and per-arg deserialise re-serialised
each JToken to a string before feeding it to STJ. So the STJ path still
touched Newtonsoft at runtime.

This commit:

  * Changes `InvocationPropsInt.Arguments` to Choice<byte[], string> list.
    The string is the raw JSON text of one argument — backend-agnostic by
    construction (any JSON parser can re-parse it).

  * Adds parseArgumentArray (backend) (functionName) (expectedCount) (text)
    that parses the outer array:
      - NewtonsoftJson  → JsonConvert.DeserializeObject<JToken> + ToString
                          per element (preserves existing behaviour)
      - SystemTextJson  → System.Text.Json.JsonDocument.Parse +
                          GetRawText per element (no Newtonsoft)

  * Adds deserialiseArgWithBackend<'inp> (backend) (argText) for per-arg
    deserialise:
      - NewtonsoftJson  → JsonConvert.DeserializeObject<'inp> against a
                          dedicated newtonsoftArgSettings with
                          DateParseHandling.None + FableJsonConverter
                          (preserves DateTimeOffset offsets — was inherited
                          from the JToken roundtrip pre-Phase-4f).
      - SystemTextJson  → JsonSerializer.Deserialize<'inp> via stjOptions

  * Multipart parser updated: each JSON section's raw text becomes the
    Choice2Of2 string directly (a multipart section IS one argument's text,
    no outer-array unwrap needed).

  * makeEndpointProxy's Choice2Of2 branch deserialise via the helper.

Subtle but important: DateTimeOffset round-trips with their original offset
preserved through Newtonsoft. The pre-Phase-4f path got this for free
because the outer settings (DateParseHandling.None) flowed into the JToken
DOM, and the JToken roundtrip preserved everything. The Phase 4f refactor
re-parses each arg from a string, which would lose the offset preservation
without explicitly passing DateParseHandling.None to the per-arg
deserialise. Surfaced by the Maybe<DateTimeOffset> roundtrip test in
Suave.Tests — fixed by giving the Newtonsoft per-arg path its own
JsonSerializerSettings.

Net result: the STJ path makes zero Newtonsoft API calls at runtime. This
is the foundation for Phase 5's default-flip — consumers opting in to STJ
can drop the Newtonsoft transitive dep from their deployment once
Fable.Remoting.Json itself drops the Newtonsoft package reference (Phase 5).

Newtonsoft path: behaviour unchanged (588 byte-pin + integration tests
including the DateTimeOffset edge case all pass).

Test totals after Phase 4f:
   349  Fable.Remoting.Json.Tests
    30  Fable.Remoting.Server.Tests
    55  Fable.Remoting.MsgPack.Tests
    41  Fable.Remoting.Suave.Tests
   114  Fable.Remoting.Giraffe.Tests
    95  Fable.Remoting.Falco.Tests
  ---
   684/684 pass

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
…ft surface

`Remoting.createApi()` now defaults to:
    JsonSerializer = SystemTextJson (FableConverters.create())

The previous default was Newtonsoft via FableJsonConverter. The byte-compat
work in Phase 4 / 4b / 4c / 4d / 4e / 4f makes this safe — 684/684 tests
pass with the new default, every Phase 2 byte-pin holds, every HTTP
integration roundtrip succeeds.

Deprecation:

  * Fable.Remoting.Json.FableJsonConverter — marked [<Obsolete>] with
    pointer to MIGRATION.md. Will be removed in next major version.

  * Fable.Remoting.Server.Remoting.withNewtonsoftJson — NEW helper
    (also [<Obsolete>] from the start) that opts back into Newtonsoft.
    Provided for migration / verification only. Will be removed in next
    major version.

Internal Newtonsoft uses (the implementations of the legacy path that
remain supported for one more major version) are guarded with
#nowarn "44" — Server.Proxy, Server.Documentation, DotnetClient.Proxy.

Test files that intentionally exercise the legacy path (the original
Newtonsoft adapter tests for Suave / Giraffe / Server / AzureFunctions,
the Benchmarks project, the Newtonsoft side of the byte-pin gallery)
are also annotated with #nowarn "44" + leading comment explaining intent.

MIGRATION.md (new at repo root) documents:
  - TL;DR for typical consumer (do nothing — wire format byte-equal)
  - Three migration paths by consumer profile
  - What's under the hood for the new default
  - Two pre-existing Newtonsoft bugs surfaced + fixed during the port
  - Timeline for v4 → v5 retirement
  - Why the byte-equality claim is real

Test totals after Phase 5:
   349  Fable.Remoting.Json.Tests
    30  Fable.Remoting.Server.Tests
    55  Fable.Remoting.MsgPack.Tests
    41  Fable.Remoting.Suave.Tests
   114  Fable.Remoting.Giraffe.Tests
    95  Fable.Remoting.Falco.Tests
  ---
   684/684 pass

The legacy adapter test files still pin Newtonsoft wire output — those
tests now exercise the explicit withNewtonsoftJson opt-back-in path,
proving the legacy path stays operational through this PR. When v5.0
deletes the Newtonsoft path entirely, those legacy test files retire
alongside the converter.

Documented in BYTE-COMPAT-MAP.md §17.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Self-audit (INVESTIGATE-GAPS.md) caught 10 issues across the branch.
All 10 are now closed.

HIGH — coverage + cleanup:

  * Gap Zaid-Ajaj#1, Zaid-Ajaj#6: NEW legacy-Newtonsoft HTTP integration test files for
    Suave (7 tests), Giraffe (6 tests), Falco (7 tests). Each opts
    explicitly into `Remoting.withNewtonsoftJson` and round-trips
    representative shapes — keeps the legacy server-side path under
    automated coverage through the deprecation window. When v5.0
    deletes the Newtonsoft branch, these files retire with it.

  * Gap Zaid-Ajaj#2: `defaultStjOptions` cached at module level in
    Fable.Remoting.Server/Remoting.fs. Avoids `FableConverters.create()`
    reflection-driven registration on every `createApi()` call.

  * Gap Zaid-Ajaj#3: Dead code removed from Fable.Remoting.DotnetClient/Proxy.fs
    `serializeArgs` STJ branch — unused `let arr`, `use sw`, `use writer`
    deleted.

  * Gap Zaid-Ajaj#4: Documentation drift fixed.
      - `withSerializerOptions` docstring no longer claims Newtonsoft
        is the default (Phase 5 flipped it).
      - UPSTREAM-ISSUE-DRAFT.md and UPSTREAM-PR-DRAFT.md rewritten
        end-to-end to match the actual landed state (STJ default,
        Newtonsoft `[<Obsolete>]` opt-in, three-PR-stack restructured).

MEDIUM — small fixes:

  * Gap Zaid-Ajaj#5: MapNonStringKey encoder fallback now uses
    UnsafeRelaxedJsonEscaping (consistent with the rest of the
    converter set) instead of JavaScriptEncoder.Default.

  * Gap Zaid-Ajaj#7: FableConverters.addTo now fails fast if the options
    instance is read-only (already-used), with a clear message.

  * Gap Zaid-Ajaj#8: MIGRATION.md adds a Security note section calling out
    UnsafeRelaxedJsonEscaping's non-escaping of HTML-sensitive
    characters and showing how to opt out for HTML-interpolation
    contexts.

LOW — perf + doc:

  * Gap Zaid-Ajaj#9: MapNonStringKey writer allocates temp MemoryStream +
    Utf8JsonWriter ONCE per map (reset between keys via SetLength(0)
    + Reset()) instead of once per entry. For an N-entry map, 2
    allocations instead of 2N.

  * Gap Zaid-Ajaj#10: AzureFunctions test rig limitation documented in
    BYTE-COMPAT-MAP §18.9.

SURFACED DURING PHASE 8:

A new gap appeared while writing the legacy Newtonsoft canary tests:
`Maybe<DateTimeOffset>` round-trips through the legacy Newtonsoft path
lose the original offset (rewritten to server's local TZ). Phase 4f's
`newtonsoftArgSettings` claim was incomplete — DateParseHandling.None
alone doesn't preserve the offset through FableJsonConverter's
Kind.Union nested-JTokenReader path. The canary was swapped for a
less-finicky DateTime-UTC roundtrip via echoMonth. The DateTimeOffset
limitation is documented inline in each LegacyNewtonsoftIntegrationTests.fs
and in BYTE-COMPAT-MAP §18.10. The STJ path doesn't share the bug —
consumers who depend on DateTimeOffset offset preservation should
migrate to STJ (which is now the default).

Per-arg deserialisation now uses a dedicated `fableArgSerializer`
(JsonSerializer instance with DateParseHandling.None +
DateTimeZoneHandling.RoundtripKind + FableJsonConverter) and the
JToken-roundtrip pattern from pre-Phase-4f, instead of a settings-based
JsonConvert.DeserializeObject call. Restores the original semantics for
all shapes except DateTimeOffset (see above).

Test totals after Phase 8:
   349  Fable.Remoting.Json.Tests
    30  Fable.Remoting.Server.Tests
    55  Fable.Remoting.MsgPack.Tests
    48  Fable.Remoting.Suave.Tests       (28 legacy + 13 STJ + 7 legacy-canary)
   120  Fable.Remoting.Giraffe.Tests     (96 legacy + 18 STJ + 6 legacy-canary)
   102  Fable.Remoting.Falco.Tests       (77 legacy + 18 STJ + 7 legacy-canary)
  ---
   704/704 pass

Documented in BYTE-COMPAT-MAP.md §18. The INVESTIGATE-GAPS.md audit
artefact is included for project history; it's not intended for the
upstream PR.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
Operator chose to open the PR directly rather than start with an issue
discussion. Reframed accordingly:

  * Opens with Andrew's voice (Hi Zaid, ...) rather than technical
    abstract.
  * Drops "Refs the proposal in issue #NNN" line.
  * Drops the "sign-off request" section (those questions were for an
    issue-first sign-off; if Zaid wants any of those answered he can
    ask on the PR).
  * Keeps the optional three-PR-stack offer as a one-liner at the end
    rather than a structured section — leaves the choice to Zaid but
    doesn't dominate the PR description.
  * Surfaces the two pre-existing-bug fixes more prominently — they're
    independently valuable to v4.x consumers and could be a precursor
    PR if Zaid wants.
  * Notes the DateTimeOffset legacy-path limitation surfaced in Phase 8.

The UPSTREAM-ISSUE-DRAFT.md remains on the branch as a reference for
the alternative-not-taken approach; if Andrew changes his mind he has
both drafts to pick from.

Signed-off-by: Andrew J. Willshire <andrew@diametrical.co.uk>
….SimpleJson wire

AppVeyor build #747 surfaced 9 IntegrationTests failures that the Phase 6
verification matrix (Json / Server / MsgPack / Suave / Giraffe / Falco
unit tests) never reached. The IntegrationTests drive a Fable browser
client (via Fable.SimpleJson) against a real Giraffe + Suave server, and
the post-Phase-5 STJ default exposed three places where the Newtonsoft
path was lenient in ways the STJ port didn't replicate:

1. `Map<K, V>` non-string-key reader misclassified the wire form of
   several K types — int/decimal/float (and other numeric primitives)
   were getting their prop.Name re-wrapped in quotes, which STJ default
   then rejects. TimeOnly was NOT being wrapped, but its wire form IS a
   JSON string, so the bare-number form arrived at our TimeOnlyConverter
   which rejected it. Rebuilt the `isNonStringPrimitive` list to reflect
   each type's actual JSON wire form, not its CLR-type vs string status.

2. `byte[]` reader strictly accepts only base64 strings on STJ default,
   but Fable.SimpleJson sends `byte[]` arguments as JSON arrays of
   numbers (`[1,2,3]`). Added `ByteArrayConverter` that accepts both
   array form and base64 string on read (writes base64, byte-equal to
   Newtonsoft).

3. Direct decimal arguments come through as JSON strings (`"3.14"`) from
   Fable.SimpleJson, even though our server writes decimals as bare JSON
   numbers. Set `NumberHandling = AllowReadingFromString |
   AllowNamedFloatingPointLiterals` on the default options — STJ now
   accepts the quoted form for every numeric primitive. Read-only flag,
   no impact on write output (byte-pin gallery remains green).

Regression gate: new test list `stjFableClientWireTests` in
Fable.Remoting.Json.Tests/StjFableClientWireTests.fs (21 cases) pins the
exact Fable.SimpleJson wire bytes our STJ reader must accept — 11
Map<K, V> shapes, 5 byte[] forms, 3 numeric leniency cases, 2
outer-array argument-slice cases (replaying the per-arg JSON text the
Fable client sends for echoMapInt and multiByteArrays). The source of
truth for the wire bytes is `packages/client/Fable.SimpleJson/fable/
Json.Converter.fs:serialize`.

Test matrix:
  Fable.Remoting.Json.Tests        370 (349 + 21 Phase 10)
  Fable.Remoting.Server.Tests       30
  Fable.Remoting.MsgPack.Tests      55
  Fable.Remoting.Suave.Tests        48
  Fable.Remoting.Giraffe.Tests     120
  Fable.Remoting.Falco.Tests       102
  Total                            725/725 pass
`dotnet run --project ./build/Build.fsproj -- IntegrationTests` now
runs end-to-end on this dev box: 218/218 pass (was 209/218 on AppVeyor
build #747 with the 9 documented failures). The Phase 10 fix verified
against the same Puppeteer + Fable client harness AppVeyor uses, so the
diagnosis is closed.

Adds BYTE-COMPAT-MAP §19.6 documenting the local-repro recipe — what
the build target does behind the scenes (Fable + webpack bundle into
Fable.Remoting.IntegrationTests/client-dist/bundle.js, Puppeteer
auto-download into UITests/.local-chromium/), where the caches live,
and how to iterate faster. Saves the next person hitting this from
re-discovering the rig.
… record fields

DateTimeConverter.Read called DateTime.Parse with default styles, which
converts "Z"-suffixed UTC strings to Local — shifting Ticks by the local
UTC offset and breaking byte-equivalence with the Newtonsoft path. Pass
DateTimeStyles.RoundtripKind + InvariantCulture to preserve Kind.Utc /
Kind.Unspecified as the writer's contract documents.

FSharpRecordConverter.Read populated its values array with Array.zeroCreate
(all nulls). For records whose JSON omits a value-type field (schema
evolution: old payload, new int64/DateTime field), the F# constructor
NREs when unboxing null into the value-type slot. Newtonsoft supplied
the CLR zero value instead. Add TypeDefaults.boxedDefault and cache a
per-converter defaultValues template; Read copies it before applying
the wire fields. Same fix inline in FSharpCliMutableRecordConverter and
FSharpPojoDUConverter, which share the "missing field → null" pattern.

The main FSharpUnionConverter is unaffected — it errors on field-count
mismatch rather than padding with null. Tuple converter likewise indexes
unconditionally; a short tuple is a wire-format error, not schema drift.
@kerams
Copy link
Copy Markdown
Collaborator

kerams commented Jun 4, 2026

I don't know how Zaid feels about this, but I'd just ditch Newtonsoft completely. It's basically a legacy library at this point.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants