Replace Newtonsoft.Json with System.Text.Json (byte-equal wire format)#393
Open
ajwillshire wants to merge 18 commits into
Open
Replace Newtonsoft.Json with System.Text.Json (byte-equal wire format)#393ajwillshire wants to merge 18 commits into
ajwillshire wants to merge 18 commits into
Conversation
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.
Collaborator
|
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 inv5.0.
Fable.Remoting.Json.Fable.Remoting.Server, all sixsibling adapters (Giraffe / Suave / Falco / AspNetCore / AwsLambda × 2
/ AzureFunctions.Worker), and
Fable.Remoting.DotnetClient.Remoting.createApi()now defaults to STJ. Wire format is byte-equalto 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 bothbackends.
FableJsonConverterand a newRemoting.withNewtonsoftJsonopt-back-in helper are
[<Obsolete>]with migration pointers.MIGRATION.mdfor 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 withfield-bearing cases. Details below — happy to split those into a
precursor PR for v4.x consumers if you'd prefer.
Motivation
Newtonsoft.Jsonis in maintenance mode;System.Text.Jsonships in theBCL on
net8.0(no new package reference needed). EveryFable.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.Jsonentirely.The client side (
Fable.SimpleJson) is already Newtonsoft-free, so theentire PR is server-side.
What landed
Fable.Remoting.Json— parallel STJ converter setNew file
Fable.Remoting.Json/FableSystemTextJsonConverter.fs(~1000 lines). Every
Kindbranch the existing NewtonsoftFableJsonConverterhandles has a matching System.Text.Json converter:KindKind.UnionFSharpUnionConverter<'T>+ factoryKind.PojoDUFSharpPojoDUConverter<'T>+ factoryKind.StringEnumFSharpStringEnumConverter<'T>+ factoryKind.OptionFSharpOptionConverter<'T>+ factoryKind.TupleFSharpTupleConverter<'T>+ factoryKind.Other(plain records)FSharpRecordConverter<'T>+ factoryKind.MutableRecordFSharpCliMutableRecordConverter<'T>+ factoryKind.MapWithStringKeyFSharpMapStringKeyConverter<'V>+ factoryKind.MapOrDictWithNonStringKeyFSharpMapNonStringKeyConverter<'K,'V>+ factoryKind.Long(int64)Int64ConverterKind.Long(uint64)UInt64ConverterKind.BigIntBigIntConverterKind.DateTimeDateTimeConverter(three-way Kind branching preserved)Kind.TimeSpanTimeSpanConverterKind.DateOnlyDateOnlyConverterKind.TimeOnlyTimeOnlyConverterKind.DataTable/Kind.DataSetDataTableConverter/DataSetConverterKind.Other(sets)FSharpSetConverter<'T>+ factoryKind.Other(lists)FSharpListConverter<'T>+ factoryPlus two converters that don't have a direct
Kindanalogue — both arebyte-compat workarounds for places STJ defaults diverge from Newtonsoft:
DoubleConverter— STJ'sWriteNumberValue(0.0)emits"0";Newtonsoft emits
"0.0". Converter restores the trailing.0forwhole-valued doubles using
ToString("R")+ appended ".0" when nodecimal/exponent is present.
StringConverter— Newtonsoft emits high-codepoint codepoints (emojietc.) as raw UTF-8 bytes.
JavaScriptEncoder.UnsafeRelaxedJsonEscapingin STJ still escapes them to
\uXXXX\uXXXXsurrogate-pair escapes inpractice (verified empirically). The converter uses
Utf8JsonWriter.WriteRawValueto bypass STJ's encoder entirely andemits only the RFC-8259-required escapes (
",\, control chars).A
FableConvertersmodule exposes the conventional registration helpers:addTovalidates that the options instance isn't already in use (STJfreezes options after first serialize call).
createreturns a fresh,fully-configured instance.
The
FSharpUnionConverterreader handles all five input shapes theNewtonsoft path supports (per
FableConverter.fs:594-659):JsonTokenType.Null→ default-of-T.String→ no-field case by name.StartObjectwith__typename(union-of-records, case-insensitive).StartObjectwith{tag, name, fields}(Fable runtime form).StartObjectsingle-property (writer round-trip —{"<Case>": value-or-array}).StartArray(["<Case>", <f>, ...]).Fable.Remoting.Server— opt-in plumbing + default flipTypes.fs— newJsonSerializerBackendDU:Plus a new
JsonSerializerfield onRemotingOptions<'context, 'serverImpl>and on the internal
MakeEndpointPropsrecord.InvocationPropsInt.Argumentschanged from
Choice<byte[], JToken> listtoChoice<byte[], string> list— the raw JSON text of each argument is backend-agnostic, so the STJ
path doesn't touch
JTokenat runtime.Remoting.fs—createApi()defaults toSystemTextJsonwith acached module-level
defaultStjOptions(oneFableConverters.create()instance reused across all
createApi()calls); newRemoting.withNewtonsoftJson[<Obsolete>]fluent helper for thelegacy opt-in; existing
Remoting.withSerializerOptionsaccepts acustom
JsonSerializerOptions.Proxy.fs—jsonSerializeWithBackend(public, so adapters canconsume),
parseArgumentArray(outer-array slicing branched onbackend),
deserialiseArgWithBackend(per-argument deserialisebranched on backend). The Newtonsoft per-arg path uses a dedicated
fableArgSerializerwithDateParseHandling.NoneandDateTimeZoneHandling.RoundtripKindto preserve as much of theoriginal 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 —
setBodyhelpers backend-awareSix adapters had a parallel
setBody-shape helper (setJsonBody/setResponseBody/ etc.) that called the NewtonsoftjsonSerializedirectly, bypassing the backend choice for error responses and the
docs schema
OPTIONS /$schemaendpoint. Each now takes aJsonSerializerBackendparameter routed fromoptions.JsonSerializer:Fable.Remoting.Giraffe/FableGiraffeAdapter.fsFable.Remoting.Suave/FableSuaveAdapter.fsFable.Remoting.Falco/FableFalcoAdapter.fsFable.Remoting.AspNetCore/Middleware.fsFable.Remoting.AwsLambda/FableLambdaAdapter.fsFable.Remoting.AwsLambda/FableLambdaApiGatewayAdapter.fsFable.Remoting.AzureFunctions.Worker/FableAzureFunctionsAdapter.fsFable.Remoting.DotnetClient— parallel opt-in surfaceProxy<'t>.WithSerializerOptions(opts: JsonSerializerOptions) : Proxy<'t>— builder member on the constructor-style API.
Remoting.withSerializerOptions opts options— fluent helper on theRemoting.createApi → buildProxypath.RemoteBuilderOptionsgains aStjOptions: JsonSerializerOptions optionfield. 14 internal
ServiceCallerFuncNtypes threadstjOptionsthrough their constructors and into
Proxy.proxyPost/proxyPostTaskcalls.
Deprecation surface
[<Obsolete>]onFable.Remoting.Json.FableJsonConverter(the legacyconverter class).
[<Obsolete>]onFable.Remoting.Server.Remoting.withNewtonsoftJson.FableJsonConverter(the implementations of thelegacy path that remain supported through the deprecation window) are
guarded with
#nowarn "44"inServer.Proxy,Server.Documentation,DotnetClient.Proxy. Test files that intentionally exercise thelegacy 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 withInvalidCastExceptionatFableConverter.fs:669— theKind.MapWithStringKeyelse-branch (array-of-pairs fallback) readserializer.Deserialize<JToken>(reader) :?> JArraywithout aJsonToken.Nullguard. Bites any Fable client that ever sendsnullfor an
Option<Map<...>>field. The STJ path doesn't share the bug(STJ's default
HandleNull = falsereturns null directly withoutinvoking the converter). Documented with an STJ-only test list in
StjWireFormatTests.fs.2.
[<Fable.Core.Pojo>]/[<Fable.Core.StringEnum>]silently ignored onDUs with field-bearing cases.
getUnionKindatFableConverter.fs:156-163read attributes from the runtimecase-subtype (e.g.
PojoDU+PojoOne), which doesn't inherit theattribute 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 dedicatednull-handling tests + 12 Pojo/StringEnum byte-pin tests. The gallery
is parameterised by an
ISerializerabstraction; the same tests runagainst both serializers byte-for-byte.
StjWireFormatTests.fs— STJ instantiation of the gallery.StjUnionPrototypeTests.fs— 23 STJ-specific reader tests coveringthe five input shapes.
Fable.Remoting.Giraffe.Tests/Suave.Tests/Falco.Testseach gainTWO new files:
StjHttpIntegrationTests.fs— STJ HTTP integration tests through realTestServer(Giraffe / Falco) or a live Suave listener.LegacyNewtonsoftIntegrationTests.fs— same shape, pinned towithNewtonsoftJson. Keeps the legacy path under automated coveragethrough 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:
Fable.Remoting.Json.TestsFable.Remoting.Server.TestsFable.Remoting.MsgPack.TestsFable.Remoting.Suave.TestsFable.Remoting.Giraffe.TestsFable.Remoting.Falco.TestsMigration story for consumers
See
MIGRATION.mdat the repo root for the full guide.The TL;DR:
Fable / DotnetClient clients receive the same bytes.
during their migration window via
|> Remoting.withNewtonsoftJson(deprecation warning fires).
FableJsonConverterdirectly (e.g. forcustom SSE serialisation) swap to
FableConverters.create()from theFable.Remoting.Json.SystemTextJsonnamespace.MIGRATION.mdincludes a security note onUnsafeRelaxedJsonEscaping'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)
Writing the legacy-canary tests surfaced that
Maybe<DateTimeOffset>round-trips throughwithNewtonsoftJsonlosethe original offset (rewritten to the server's local TZ). The STJ
path doesn't share the bug. Root cause appears to be in
FableJsonConverter'sKind.Unionsingle-field-case branch — theinner
JTokenReader.CreateReader()doesn't fully inheritDateParseHandling.Nonefrom the outer serializer. Documented inMIGRATION.md as a "migrate to STJ if you depend on this" item. v5.0's
deletion of the Newtonsoft path makes the limitation moot.
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.
contents enable deleting
Fable.Remoting.Json/FableConverter.fs,dropping
Newtonsoft.JsonfromFable.Remoting.Json/paket.references,removing the
NewtonsoftJsoncase fromJsonSerializerBackend,removing
Remoting.withNewtonsoftJson, and retiring everyLegacyNewtonsoftIntegrationTests.fsplus the pre-existingNewtonsoft-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 artefactdocumenting 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
The suites are Expecto console runners (
<OutputType>Exe</OutputType>);use
dotnet run, notdotnet test(which silently exits zero — knownupstream pattern).
Diff stats
Most of the insertions are documentation (
BYTE-COMPAT-MAP.md+MIGRATION.md) and the test gallery (parallel STJ runs of the byte-pingallery, 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) isuntouched except for two bug fixes (
getUnionKindnormalisation +documenting the
[<Obsolete>]rationale) and the deprecationannotation.