diff --git a/Jint.Tests.PublicInterface/WebApiBase64Tests.cs b/Jint.Tests.PublicInterface/WebApiBase64Tests.cs
new file mode 100644
index 0000000000..03a48189af
--- /dev/null
+++ b/Jint.Tests.PublicInterface/WebApiBase64Tests.cs
@@ -0,0 +1,106 @@
+#if NET8_0_OR_GREATER
+using Jint;
+using Jint.Native;
+using Jint.Runtime;
+
+namespace Jint.Tests.PublicInterface;
+
+///
+/// The Base64 web API feature seen from outside the assembly: what a host writes to get
+/// atob and btoa, what it gets when it writes nothing, and the property shape a script
+/// finds them in.
+///
+public class WebApiBase64Tests
+{
+ [Fact]
+ public void ADefaultEngineHasNeitherFunction()
+ {
+ var engine = new Engine();
+
+ engine.Evaluate("typeof atob").AsString().Should().Be("undefined");
+ engine.Evaluate("typeof btoa").AsString().Should().Be("undefined");
+ engine.Evaluate("'btoa' in globalThis").AsBoolean().Should().BeFalse();
+ }
+
+ [Fact]
+ public void TheFeatureFlagInstallsBoth()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64));
+
+ engine.Evaluate("btoa('hello')").AsString().Should().Be("aGVsbG8=");
+ engine.Evaluate("atob('aGVsbG8=')").AsString().Should().Be("hello");
+
+ // Asking for base64 alone does not bring anything else along.
+ engine.Evaluate("typeof TextEncoder").AsString().Should().Be("undefined");
+ engine.Evaluate("typeof console").AsString().Should().Be("undefined");
+ }
+
+ [Fact]
+ public void TheDefaultSetIncludesIt()
+ {
+ var engine = new Engine(options => options.UseWebApis());
+
+ engine.Evaluate("typeof atob").AsString().Should().Be("function");
+ engine.Evaluate("typeof btoa").AsString().Should().Be("function");
+ }
+
+ [Fact]
+ public void GivesTheFunctionsTheAttributesWebIdlAsksFor()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64));
+
+ // Operations of a WebIDL interface mixin are enumerable, unlike interface objects —
+ // https://webidl.spec.whatwg.org/#es-operations.
+ foreach (var name in new[] { "atob", "btoa" })
+ {
+ engine.SetValue("name", name);
+ var descriptor = engine.Evaluate("Object.getOwnPropertyDescriptor(globalThis, name)").AsObject();
+
+ descriptor.Get("writable").AsBoolean().Should().BeTrue();
+ descriptor.Get("configurable").AsBoolean().Should().BeTrue();
+ descriptor.Get("enumerable").AsBoolean().Should().BeTrue();
+ }
+ }
+
+ [Fact]
+ public void AHostRegisteredGlobalWins()
+ {
+ var marker = new JsString("host's own btoa");
+
+ var engine = new Engine(options => options
+ .AddLazyGlobal("btoa", _ => marker)
+ .UseWebApis(WebApiFeatures.Base64));
+
+ engine.Evaluate("btoa").Should().BeSameAs(marker);
+ engine.Evaluate("typeof atob").AsString().Should().Be("function");
+ }
+
+ [Fact]
+ public void ReportsAFailureAsADomExceptionAHostCanRead()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64));
+
+ var exception = Assert.Throws(() => engine.Evaluate("btoa('\\u20ac')"));
+
+ exception.Error.Get("name").AsString().Should().Be("InvalidCharacterError");
+ exception.Error.Get("code").AsNumber().Should().Be(5);
+
+ // It is a real Error, so a host logging it gets a message and a stack.
+ engine.SetValue("thrown", exception.Error);
+ engine.Evaluate("thrown instanceof Error").AsBoolean().Should().BeTrue();
+ engine.Evaluate("typeof thrown.stack").AsString().Should().Be("string");
+ }
+
+ [Fact]
+ public void SurvivesAGlobalSnapshotRestore()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64));
+
+ var snapshot = engine.Advanced.CaptureGlobalSnapshot();
+ engine.Evaluate("btoa('first')");
+ engine.Advanced.RestoreGlobalSnapshot(snapshot);
+
+ engine.Evaluate("btoa('second')").AsString().Should().Be("c2Vjb25k");
+ }
+}
+#endif
diff --git a/Jint.Tests.PublicInterface/WebApiEncodingTests.cs b/Jint.Tests.PublicInterface/WebApiEncodingTests.cs
new file mode 100644
index 0000000000..c046303015
--- /dev/null
+++ b/Jint.Tests.PublicInterface/WebApiEncodingTests.cs
@@ -0,0 +1,115 @@
+#if NET8_0_OR_GREATER
+using Jint;
+using Jint.Native;
+
+namespace Jint.Tests.PublicInterface;
+
+///
+/// The Encoding web API feature seen from outside the assembly: what a host writes to get
+/// TextEncoder and TextDecoder, what it gets when it writes nothing, and the property shape
+/// a script finds them in.
+///
+public class WebApiEncodingTests
+{
+ [Fact]
+ public void ADefaultEngineHasNeitherInterface()
+ {
+ var engine = new Engine();
+
+ engine.Evaluate("typeof TextEncoder").AsString().Should().Be("undefined");
+ engine.Evaluate("typeof TextDecoder").AsString().Should().Be("undefined");
+ engine.Evaluate("'TextEncoder' in globalThis").AsBoolean().Should().BeFalse();
+ }
+
+ [Fact]
+ public void TheFeatureFlagInstallsBoth()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Encoding));
+
+ engine.Evaluate("typeof TextEncoder").AsString().Should().Be("function");
+ engine.Evaluate("typeof TextDecoder").AsString().Should().Be("function");
+
+ // Asking for encoding alone does not bring anything else along.
+ engine.Evaluate("typeof console").AsString().Should().Be("undefined");
+ engine.Evaluate("typeof atob").AsString().Should().Be("undefined");
+
+ // DOMException comes with any feature, since it is how they all report failure.
+ engine.Evaluate("typeof DOMException").AsString().Should().Be("function");
+ }
+
+ [Fact]
+ public void TheDefaultSetIncludesIt()
+ {
+ var engine = new Engine(options => options.UseWebApis());
+
+ engine.Evaluate("typeof TextEncoder").AsString().Should().Be("function");
+ engine.Evaluate("typeof TextDecoder").AsString().Should().Be("function");
+ }
+
+ [Fact]
+ public void AnotherFeatureAloneDoesNotInstallThem()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Base64));
+
+ engine.Evaluate("typeof TextEncoder").AsString().Should().Be("undefined");
+ engine.Evaluate("typeof TextDecoder").AsString().Should().Be("undefined");
+ }
+
+ [Fact]
+ public void GivesTheInterfaceObjectsTheAttributesWebIdlAsksFor()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Encoding));
+
+ // An interface object is writable and configurable but not enumerable —
+ // https://webidl.spec.whatwg.org/#es-interfaces.
+ foreach (var name in new[] { "TextEncoder", "TextDecoder" })
+ {
+ engine.SetValue("name", name);
+ var descriptor = engine.Evaluate("Object.getOwnPropertyDescriptor(globalThis, name)").AsObject();
+
+ descriptor.Get("writable").AsBoolean().Should().BeTrue();
+ descriptor.Get("configurable").AsBoolean().Should().BeTrue();
+ descriptor.Get("enumerable").AsBoolean().Should().BeFalse();
+ }
+ }
+
+ [Fact]
+ public void AHostRegisteredGlobalWins()
+ {
+ var marker = new JsString("host's own TextEncoder");
+
+ var engine = new Engine(options => options
+ .AddLazyGlobal("TextEncoder", _ => marker)
+ .UseWebApis(WebApiFeatures.Encoding));
+
+ // The host's configuration runs first and the install is non-clobbering.
+ engine.Evaluate("TextEncoder").Should().BeSameAs(marker);
+
+ // ... and the name it did not claim is still installed.
+ engine.Evaluate("typeof TextDecoder").AsString().Should().Be("function");
+ }
+
+ [Fact]
+ public void EncodesAndDecodesForTheHost()
+ {
+ var engine = new Engine(options => options.UseWebApis(WebApiFeatures.Encoding));
+
+ engine.Evaluate("new TextEncoder().encode('Grüße').join(',')").AsString().Should().Be("71,114,195,188,195,159,101");
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([71, 114, 195, 188, 195, 159, 101]))").AsString().Should().Be("Grüße");
+ }
+
+ [Fact]
+ public void OneOptionsInstanceServesSeveralEngines()
+ {
+ var options = new Options().UseWebApis(WebApiFeatures.Encoding);
+
+ var first = new Engine(options);
+ var second = new Engine(options);
+
+ // Decoder state is per instance and therefore per engine; nothing lives on the shared options.
+ first.Evaluate("var d = new TextDecoder(); d.decode(new Uint8Array([0xE2, 0x82]), { stream: true });");
+ second.Evaluate("new TextDecoder().decode(new Uint8Array([0xAC])).charCodeAt(0)").AsNumber().Should().Be(0xFFFD);
+ first.Evaluate("d.decode(new Uint8Array([0xAC]))").AsString().Should().Be("€");
+ }
+}
+#endif
diff --git a/Jint.Tests/Runtime/WebApi/Base64Tests.cs b/Jint.Tests/Runtime/WebApi/Base64Tests.cs
new file mode 100644
index 0000000000..94f10580fb
--- /dev/null
+++ b/Jint.Tests/Runtime/WebApi/Base64Tests.cs
@@ -0,0 +1,198 @@
+#if NET8_0_OR_GREATER
+#nullable enable
+
+using Jint.Runtime;
+
+namespace Jint.Tests.Runtime.WebApi;
+
+///
+/// atob and btoa as HTML specifies them —
+/// https://html.spec.whatwg.org/multipage/webappapis.html#atob — over Infra's forgiving-base64
+/// algorithms, https://infra.spec.whatwg.org/#forgiving-base64.
+///
+public class Base64Tests
+{
+ private static Engine WebEngine() => new(options => options.UseWebApis(WebApiFeatures.Base64));
+
+ [Theory]
+ [InlineData("", "")]
+ [InlineData("f", "Zg==")]
+ [InlineData("fo", "Zm8=")]
+ [InlineData("foo", "Zm9v")]
+ [InlineData("foob", "Zm9vYg==")]
+ [InlineData("fooba", "Zm9vYmE=")]
+ [InlineData("foobar", "Zm9vYmFy")]
+ [InlineData("hello world", "aGVsbG8gd29ybGQ=")]
+ public void BtoaEncodesTheRfc4648Vectors(string input, string expected)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("input", input);
+ engine.Evaluate("btoa(input)").AsString().Should().Be(expected);
+ }
+
+ [Fact]
+ public void BtoaTakesOneByteFromEachCodeUnit()
+ {
+ var engine = WebEngine();
+
+ // U+00FF is the highest code unit it accepts, and it becomes the byte 0xFF.
+ engine.Evaluate("btoa('\\u00ff')").AsString().Should().Be("/w==");
+ engine.Evaluate("btoa('\\u0000\\u0001\\u00fe\\u00ff')").AsString().Should().Be("AAH+/w==");
+ }
+
+ [Theory]
+ [InlineData("\\u0100")]
+ [InlineData("a\\u0100b")]
+ [InlineData("€")]
+ // btoa takes a DOMString, not a USVString, so a lone surrogate is not replaced by U+FFFD first — it
+ // is simply a code unit above U+00FF.
+ [InlineData("\\ud800")]
+ [InlineData("😀")]
+ public void BtoaRefusesACodeUnitAboveLatin1(string input)
+ {
+ var engine = WebEngine();
+
+ var exception = Assert.Throws(() => engine.Evaluate($"btoa('{input}')"));
+
+ exception.Error.Get("name").AsString().Should().Be("InvalidCharacterError");
+ exception.Error.Get("code").AsNumber().Should().Be(5);
+ engine.SetValue("thrown", exception.Error);
+ engine.Evaluate("thrown instanceof DOMException").AsBoolean().Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("", "")]
+ [InlineData("Zg==", "f")]
+ [InlineData("Zm8=", "fo")]
+ [InlineData("Zm9v", "foo")]
+ [InlineData("aGVsbG8gd29ybGQ=", "hello world")]
+ // Unpadded input is accepted: this is where "forgiving" starts.
+ [InlineData("Zg", "f")]
+ [InlineData("Zm8", "fo")]
+ [InlineData("Zm9vYg", "foob")]
+ public void AtobDecodes(string input, string expected)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("input", input);
+ engine.Evaluate("atob(input)").AsString().Should().Be(expected);
+ }
+
+ [Theory]
+ // Step 1 removes ASCII whitespace from anywhere, not just the ends.
+ [InlineData(" Zm9v ")]
+ [InlineData("Zm\n9v")]
+ [InlineData("Z m 9 v")]
+ [InlineData("\tZ\rm\f9v")]
+ [InlineData("Zm9v\n")]
+ public void AtobStripsAsciiWhitespaceAnywhere(string input)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("input", input);
+ engine.Evaluate("atob(input)").AsString().Should().Be("foo");
+ }
+
+ [Theory]
+ // A code point outside the alphabet.
+ [InlineData("a*bc")]
+ [InlineData("ab-c")]
+ [InlineData("ab_c")]
+ [InlineData("Zm9v!")]
+ // U+000B VT is not ASCII whitespace, so it is a stray code point rather than something to strip.
+ [InlineData("Zm\u000B9v")]
+ // Step 3: a length that leaves a remainder of one.
+ [InlineData("a")]
+ [InlineData("abcde")]
+ // Padding is only removed when the length already divides by four, so these keep their '=' and then
+ // fail the alphabet check.
+ [InlineData("Zg=")]
+ [InlineData("Zg===")]
+ [InlineData("=")]
+ [InlineData("==")]
+ [InlineData("====")]
+ [InlineData("Zm9vYg====")]
+ // '=' in the middle is never removed.
+ [InlineData("Z=9v")]
+ public void AtobRefusesWhatForgivingBase64CallsFailure(string input)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("input", input);
+ var exception = Assert.Throws(() => engine.Evaluate("atob(input)"));
+
+ exception.Error.Get("name").AsString().Should().Be("InvalidCharacterError");
+ exception.Error.Get("code").AsNumber().Should().Be(5);
+ }
+
+ [Fact]
+ public void AtobRemovesAtMostTwoPaddingCodePoints()
+ {
+ var engine = WebEngine();
+
+ // Four '=' is a length of four, so two are removed and the remaining two are stray code points.
+ Assert.Throws(() => engine.Evaluate("atob('====')"));
+
+ // Eight characters ending in two '=' is fine ...
+ engine.Evaluate("atob('Zm9vYg==')").AsString().Should().Be("foob");
+
+ // ... and the padding may itself be split by whitespace, since step 1 runs first.
+ engine.Evaluate("atob('Zm9vYg= =')").AsString().Should().Be("foob");
+ }
+
+ [Fact]
+ public void AtobProducesAByteString()
+ {
+ var engine = WebEngine();
+
+ // Every code unit of the result is a byte, U+0000 to U+00FF.
+ engine.Evaluate("""
+ const decoded = atob('AAH+/w==');
+ [decoded.length, decoded.charCodeAt(0), decoded.charCodeAt(1), decoded.charCodeAt(2), decoded.charCodeAt(3)].join(',');
+ """).AsString().Should().Be("4,0,1,254,255");
+ }
+
+ [Fact]
+ public void RoundTripsEveryByteValue()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ let all = '';
+ for (let i = 0; i < 256; i++) { all += String.fromCharCode(i); }
+ atob(btoa(all)) === all;
+ """).AsBoolean().Should().BeTrue();
+ }
+
+ [Fact]
+ public void CoercesItsArgumentToAString()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("btoa(123)").AsString().Should().Be("MTIz");
+ engine.Evaluate("btoa(null)").AsString().Should().Be("bnVsbA==");
+ engine.Evaluate("btoa({ toString() { return 'foo'; } })").AsString().Should().Be("Zm9v");
+ engine.Evaluate("atob({ toString() { return 'Zm9v'; } })").AsString().Should().Be("foo");
+
+ // "undefined" is nine code points, and step 3 refuses a length that leaves a remainder of one.
+ Assert.Throws(() => engine.Evaluate("atob(undefined)"));
+ }
+
+ [Fact]
+ public void AreOrdinaryGlobalFunctions()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("typeof atob").AsString().Should().Be("function");
+ engine.Evaluate("typeof btoa").AsString().Should().Be("function");
+ engine.Evaluate("atob.length").AsNumber().Should().Be(1);
+ engine.Evaluate("btoa.length").AsNumber().Should().Be(1);
+ engine.Evaluate("atob.name").AsString().Should().Be("atob");
+ engine.Evaluate("btoa.name").AsString().Should().Be("btoa");
+
+ // They are not constructors.
+ Assert.Throws(() => engine.Evaluate("new btoa('a')"));
+ }
+}
+#endif
diff --git a/Jint.Tests/Runtime/WebApi/EncodingTests.cs b/Jint.Tests/Runtime/WebApi/EncodingTests.cs
new file mode 100644
index 0000000000..63216bb2d7
--- /dev/null
+++ b/Jint.Tests/Runtime/WebApi/EncodingTests.cs
@@ -0,0 +1,567 @@
+#if NET8_0_OR_GREATER
+#nullable enable
+
+using Jint.Runtime;
+
+namespace Jint.Tests.Runtime.WebApi;
+
+///
+/// TextEncoder and TextDecoder as the Encoding Standard specifies them —
+/// https://encoding.spec.whatwg.org/#api.
+///
+public class EncodingTests
+{
+ private static Engine WebEngine() => new(options => options.UseWebApis(WebApiFeatures.Encoding));
+
+ #region TextEncoder
+
+ [Fact]
+ public void TextEncoderIsUtf8AndNothingElse()
+ {
+ var engine = WebEngine();
+
+ // "The encoding is always utf-8" — https://encoding.spec.whatwg.org/#dom-textencoder-encoding.
+ engine.Evaluate("new TextEncoder().encoding").AsString().Should().Be("utf-8");
+ engine.Evaluate("TextEncoder.length").AsNumber().Should().Be(0);
+ engine.Evaluate("TextEncoder.name").AsString().Should().Be("TextEncoder");
+ }
+
+ [Fact]
+ public void EncodeDefaultsToTheEmptyString()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextEncoder().encode().length").AsNumber().Should().Be(0);
+ engine.Evaluate("new TextEncoder().encode(undefined).length").AsNumber().Should().Be(0);
+ engine.Evaluate("new TextEncoder().encode('') instanceof Uint8Array").AsBoolean().Should().BeTrue();
+ }
+
+ [Theory]
+ [InlineData("hi", "104,105")]
+ [InlineData("é", "195,169")]
+ [InlineData("€", "226,130,172")]
+ [InlineData("😀", "240,159,152,128")]
+ public void EncodeProducesUtf8(string input, string expected)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("input", input);
+ engine.Evaluate("new TextEncoder().encode(input).join(',')").AsString().Should().Be(expected);
+ }
+
+ [Fact]
+ public void EncodeReplacesAnUnpairedSurrogate()
+ {
+ var engine = WebEngine();
+
+ // The argument is a USVString, so a lone surrogate becomes U+FFFD before it is ever encoded.
+ engine.Evaluate("new TextEncoder().encode('\\uD800').join(',')").AsString().Should().Be("239,191,189");
+ engine.Evaluate("new TextEncoder().encode('\\uDC00').join(',')").AsString().Should().Be("239,191,189");
+ engine.Evaluate("new TextEncoder().encode('a\\uD800b').join(',')").AsString().Should().Be("97,239,191,189,98");
+ }
+
+ [Fact]
+ public void EncodeIntoReportsCodeUnitsReadAndBytesWritten()
+ {
+ var engine = WebEngine();
+
+ var result = engine.Evaluate("""
+ const r = new TextEncoder().encodeInto('hi€', new Uint8Array(16));
+ [r.read, r.written].join(',');
+ """);
+
+ // Three code units in, five bytes out.
+ result.AsString().Should().Be("3,5");
+ }
+
+ [Fact]
+ public void EncodeIntoCountsASurrogatePairAsTwoCodeUnits()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ const pair = new TextEncoder().encodeInto('😀', new Uint8Array(8));
+ [pair.read, pair.written].join(',');
+ """).AsString().Should().Be("2,4");
+
+ // ... and the U+FFFD an unpaired surrogate becomes as one, since it is one code unit.
+ engine.Evaluate("""
+ const lone = new TextEncoder().encodeInto('\uD800', new Uint8Array(8));
+ [lone.read, lone.written].join(',');
+ """).AsString().Should().Be("1,3");
+ }
+
+ [Fact]
+ public void EncodeIntoNeverSplitsACodePoint()
+ {
+ var engine = WebEngine();
+
+ // Three bytes are needed for '€' and only two are left, so nothing at all is written for it.
+ engine.Evaluate("""
+ const tooSmall = new Uint8Array(3);
+ const partial = new TextEncoder().encodeInto('a€', tooSmall);
+ [partial.read, partial.written, tooSmall.join(',')].join('|');
+ """).AsString().Should().Be("1|1|97,0,0");
+
+ // With exactly enough room it fits.
+ engine.Evaluate("""
+ const exact = new Uint8Array(4);
+ const full = new TextEncoder().encodeInto('a€', exact);
+ [full.read, full.written, exact.join(',')].join('|');
+ """).AsString().Should().Be("2|4|97,226,130,172");
+ }
+
+ [Fact]
+ public void EncodeIntoWritesThroughAViewsOwnWindow()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ const buffer = new Uint8Array(6);
+ const window = buffer.subarray(2, 5);
+ const r = new TextEncoder().encodeInto('abcd', window);
+ [r.read, r.written, buffer.join(',')].join('|');
+ """).AsString().Should().Be("3|3|0,0,97,98,99,0");
+ }
+
+ [Fact]
+ public void EncodeIntoRefusesADestinationThatIsNotAUint8Array()
+ {
+ var engine = WebEngine();
+
+ foreach (var destination in new[] { "new Uint16Array(8)", "new Uint8ClampedArray(8)", "new ArrayBuffer(8)", "[]", "undefined" })
+ {
+ Assert.Throws(() => engine.Evaluate($"new TextEncoder().encodeInto('a', {destination})"))
+ .Error.Get("name").AsString().Should().Be("TypeError");
+ }
+ }
+
+ [Fact]
+ public void TextEncoderRequiresNewAndBrandChecksItsReceiver()
+ {
+ var engine = WebEngine();
+
+ Assert.Throws(() => engine.Evaluate("TextEncoder()"));
+ Assert.Throws(() => engine.Evaluate("TextEncoder.prototype.encode.call({})"));
+ Assert.Throws(() => engine.Evaluate("Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encoding').get.call({})"));
+
+ // The prototype is not itself a TextEncoder, which is what a brand check means.
+ Assert.Throws(() => engine.Evaluate("TextEncoder.prototype.encoding"));
+ }
+
+ #endregion
+
+ #region TextDecoder labels
+
+ [Fact]
+ public void TextDecoderDefaultsToUtf8()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder().encoding").AsString().Should().Be("utf-8");
+ engine.Evaluate("new TextDecoder().fatal").AsBoolean().Should().BeFalse();
+ engine.Evaluate("new TextDecoder().ignoreBOM").AsBoolean().Should().BeFalse();
+ engine.Evaluate("new TextDecoder(undefined, undefined).encoding").AsString().Should().Be("utf-8");
+ }
+
+ [Theory]
+ // https://encoding.spec.whatwg.org/#names-and-labels, the UTF-8 row.
+ [InlineData("utf-8", "utf-8")]
+ [InlineData("utf8", "utf-8")]
+ [InlineData("UTF-8", "utf-8")]
+ [InlineData("unicode-1-1-utf-8", "utf-8")]
+ [InlineData("unicode11utf8", "utf-8")]
+ [InlineData("unicode20utf8", "utf-8")]
+ [InlineData("x-unicode20utf8", "utf-8")]
+ // The UTF-16LE row — note that a bare "utf-16" is little-endian.
+ [InlineData("utf-16", "utf-16le")]
+ [InlineData("utf-16le", "utf-16le")]
+ [InlineData("UTF-16LE", "utf-16le")]
+ [InlineData("unicode", "utf-16le")]
+ [InlineData("unicodefeff", "utf-16le")]
+ [InlineData("csunicode", "utf-16le")]
+ [InlineData("iso-10646-ucs-2", "utf-16le")]
+ [InlineData("ucs-2", "utf-16le")]
+ // The UTF-16BE row.
+ [InlineData("utf-16be", "utf-16be")]
+ [InlineData("unicodefffe", "utf-16be")]
+ // "Get an encoding" strips leading and trailing ASCII whitespace first.
+ [InlineData(" utf-8\t\n", "utf-8")]
+ [InlineData("\r\futf8 ", "utf-8")]
+ public void ResolvesALabelToItsEncodingName(string label, string expected)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("label", label);
+ engine.Evaluate("new TextDecoder(label).encoding").AsString().Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("windows-1252")]
+ [InlineData("iso-8859-1")]
+ [InlineData("shift_jis")]
+ [InlineData("replacement")]
+ [InlineData("x-user-defined")]
+ [InlineData("")]
+ [InlineData("utf-9")]
+ // ASCII case-insensitive matching and nothing more: U+017F must not fold onto 's'.
+ [InlineData("cſunicode")]
+ // U+000B VT is not ASCII whitespace, so it is not stripped.
+ [InlineData("\u000Butf-8")]
+ public void RefusesALabelOutsideTheImplementedSubset(string label)
+ {
+ var engine = WebEngine();
+
+ engine.SetValue("label", label);
+ Assert.Throws(() => engine.Evaluate("new TextDecoder(label)"))
+ .Error.Get("name").AsString().Should().Be("RangeError");
+ }
+
+ [Fact]
+ public void ReadsTheOptionsDictionary()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder('utf-8', { fatal: true }).fatal").AsBoolean().Should().BeTrue();
+ engine.Evaluate("new TextDecoder('utf-8', { ignoreBOM: true }).ignoreBOM").AsBoolean().Should().BeTrue();
+
+ // Dictionary members are coerced with ToBoolean, and an absent one takes its default.
+ engine.Evaluate("new TextDecoder('utf-8', { fatal: 1 }).fatal").AsBoolean().Should().BeTrue();
+ engine.Evaluate("new TextDecoder('utf-8', { fatal: undefined }).fatal").AsBoolean().Should().BeFalse();
+ engine.Evaluate("new TextDecoder('utf-8', null).fatal").AsBoolean().Should().BeFalse();
+ }
+
+ [Fact]
+ public void ConvertsBothArgumentsBeforeTheLabelIsLookedUp()
+ {
+ var engine = WebEngine();
+
+ // WebIDL converts the arguments and only then runs the constructor steps, so the getter runs even
+ // though the label is about to be refused.
+ var result = engine.Evaluate("""
+ let seen = false;
+ try {
+ new TextDecoder('nonsense', { get fatal() { seen = true; return false; } });
+ } catch (e) {
+ seen + ':' + e.name;
+ }
+ """);
+
+ result.AsString().Should().Be("true:RangeError");
+ }
+
+ #endregion
+
+ #region TextDecoder decoding
+
+ [Fact]
+ public void DecodesEveryBufferSourceShape()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([104, 105]))").AsString().Should().Be("hi");
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([104, 105]).buffer)").AsString().Should().Be("hi");
+ engine.Evaluate("new TextDecoder().decode(new DataView(new Uint8Array([104, 105]).buffer))").AsString().Should().Be("hi");
+
+ // A view's own window, not the whole buffer.
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([104, 105, 106, 107]).subarray(1, 3))").AsString().Should().Be("ij");
+ engine.Evaluate("new TextDecoder().decode(new DataView(new Uint8Array([104, 105, 106, 107]).buffer, 2))").AsString().Should().Be("jk");
+ }
+
+ [Fact]
+ public void DecodesNothingWhenGivenNothing()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder().decode()").AsString().Should().Be("");
+ engine.Evaluate("new TextDecoder().decode(undefined)").AsString().Should().Be("");
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array(0))").AsString().Should().Be("");
+ }
+
+ [Fact]
+ public void RefusesAnInputThatIsNotABufferSource()
+ {
+ var engine = WebEngine();
+
+ // The IDL type is not nullable, so null is not the same as omitting the argument.
+ foreach (var input in new[] { "null", "'abc'", "42", "[]", "{}" })
+ {
+ Assert.Throws(() => engine.Evaluate($"new TextDecoder().decode({input})"))
+ .Error.Get("name").AsString().Should().Be("TypeError");
+ }
+ }
+
+ [Fact]
+ public void ReplacesInvalidUtf8WithU00FFFD()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([0xFF])).charCodeAt(0)").AsNumber().Should().Be(0xFFFD);
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([0x61, 0xC0, 0x62]))").AsString().Should().Be("a\uFFFDb");
+ }
+
+ [Fact]
+ public void FatalTurnsInvalidInputIntoATypeError()
+ {
+ var engine = WebEngine();
+
+ Assert.Throws(() => engine.Evaluate("new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array([0xFF]))"))
+ .Error.Get("name").AsString().Should().Be("TypeError");
+
+ // An incomplete sequence at the end of a non-streaming decode is invalid too, because the decode
+ // flushes.
+ Assert.Throws(() => engine.Evaluate("new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array([0xE2, 0x82]))"))
+ .Error.Get("name").AsString().Should().Be("TypeError");
+
+ // ... and a decoder that threw is usable again.
+ engine.Evaluate("""
+ const decoder = new TextDecoder('utf-8', { fatal: true });
+ try { decoder.decode(new Uint8Array([0xFF])); } catch (e) { }
+ decoder.decode(new Uint8Array([104, 105]));
+ """).AsString().Should().Be("hi");
+ }
+
+ [Fact]
+ public void DecodesUtf16InBothEndiannesses()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0x68, 0x00, 0x69, 0x00]))").AsString().Should().Be("hi");
+ engine.Evaluate("new TextDecoder('utf-16be').decode(new Uint8Array([0x00, 0x68, 0x00, 0x69]))").AsString().Should().Be("hi");
+
+ // A surrogate pair, so the code unit order matters.
+ engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0x3D, 0xD8, 0x00, 0xDE]))").AsString().Should().Be("😀");
+ engine.Evaluate("new TextDecoder('utf-16be').decode(new Uint8Array([0xD8, 0x3D, 0xDE, 0x00]))").AsString().Should().Be("😀");
+ }
+
+ [Fact]
+ public void ReplacesAnUnpairedUtf16Surrogate()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0x00, 0xD8, 0x61, 0x00])).charCodeAt(0)")
+ .AsNumber().Should().Be(0xFFFD);
+ }
+
+ #endregion
+
+ #region Streaming
+
+ [Fact]
+ public void StreamsACodePointSplitAcrossTwoChunks()
+ {
+ var engine = WebEngine();
+
+ // '€' is E2 82 AC; the first chunk ends mid-sequence and must produce nothing.
+ engine.Evaluate("""
+ const decoder = new TextDecoder();
+ const first = decoder.decode(new Uint8Array([0x61, 0xE2, 0x82]), { stream: true });
+ const second = decoder.decode(new Uint8Array([0xAC, 0x62]));
+ [first, second].join('|');
+ """).AsString().Should().Be("a|€b");
+ }
+
+ [Fact]
+ public void ANonStreamingDecodeEndsTheStream()
+ {
+ var engine = WebEngine();
+
+ // The held-over bytes of the first chunk are flushed as U+FFFD by the next non-streaming call, and
+ // the call after that starts a fresh stream rather than continuing.
+ engine.Evaluate("""
+ const decoder = new TextDecoder();
+ decoder.decode(new Uint8Array([0xE2, 0x82]), { stream: true });
+ const flushed = decoder.decode();
+ const fresh = decoder.decode(new Uint8Array([0xAC]));
+ [flushed.charCodeAt(0), fresh.charCodeAt(0)].join(',');
+ """).AsString().Should().Be("65533,65533");
+ }
+
+ [Fact]
+ public void StreamsAUtf16CodeUnitSplitAcrossTwoChunks()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ const decoder = new TextDecoder('utf-16le');
+ const first = decoder.decode(new Uint8Array([0x68]), { stream: true });
+ const second = decoder.decode(new Uint8Array([0x00, 0x69, 0x00]));
+ [first, second].join('|');
+ """).AsString().Should().Be("|hi");
+ }
+
+ [Fact]
+ public void FatalDoesNotFireOnAnIncompleteSequenceMidStream()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ const decoder = new TextDecoder('utf-8', { fatal: true });
+ decoder.decode(new Uint8Array([0xE2, 0x82]), { stream: true });
+ decoder.decode(new Uint8Array([0xAC]));
+ """).AsString().Should().Be("€");
+ }
+
+ #endregion
+
+ #region Byte order mark
+
+ [Fact]
+ public void StripsOneLeadingBomPerStream()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61]))").AsString().Should().Be("a");
+
+ // Only the first one: a second U+FEFF is content.
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0xEF, 0xBB, 0xBF])).charCodeAt(0)")
+ .AsNumber().Should().Be(0xFEFF);
+
+ // And only when it leads.
+ engine.Evaluate("new TextDecoder().decode(new Uint8Array([0x61, 0xEF, 0xBB, 0xBF])).length").AsNumber().Should().Be(2);
+ }
+
+ [Fact]
+ public void KeepsTheBomWhenIgnoreBomIsSet()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder('utf-8', { ignoreBOM: true }).decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61])).length")
+ .AsNumber().Should().Be(2);
+ engine.Evaluate("new TextDecoder('utf-8', { ignoreBOM: true }).decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61])).charCodeAt(0)")
+ .AsNumber().Should().Be(0xFEFF);
+ }
+
+ [Fact]
+ public void StripsABomSplitAcrossChunks()
+ {
+ var engine = WebEngine();
+
+ // The BOM is dropped by the serialize step, which sees scalar values rather than bytes, so it does
+ // not matter that its three bytes arrived in two pieces.
+ engine.Evaluate("""
+ const decoder = new TextDecoder();
+ const first = decoder.decode(new Uint8Array([0xEF, 0xBB]), { stream: true });
+ const second = decoder.decode(new Uint8Array([0xBF, 0x61]));
+ [first, second].join('|');
+ """).AsString().Should().Be("|a");
+ }
+
+ [Fact]
+ public void StripsTheBomOnlyOnceAcrossAWholeStream()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ const decoder = new TextDecoder();
+ const first = decoder.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x61]), { stream: true });
+ const second = decoder.decode(new Uint8Array([0xEF, 0xBB, 0xBF, 0x62]));
+ [first, second.charCodeAt(0), second.length].join('|');
+ """).AsString().Should().Be("a|65279|2");
+ }
+
+ [Fact]
+ public void StripsTheUtf16Bom()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextDecoder('utf-16le').decode(new Uint8Array([0xFF, 0xFE, 0x61, 0x00]))").AsString().Should().Be("a");
+ engine.Evaluate("new TextDecoder('utf-16be').decode(new Uint8Array([0xFE, 0xFF, 0x00, 0x61]))").AsString().Should().Be("a");
+ }
+
+ #endregion
+
+ #region Shape
+
+ [Fact]
+ public void TextDecoderBrandChecksItsReceiver()
+ {
+ var engine = WebEngine();
+
+ Assert.Throws(() => engine.Evaluate("TextDecoder()"));
+ Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.decode.call({})"));
+ Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.encoding"));
+ Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.fatal"));
+ Assert.Throws(() => engine.Evaluate("TextDecoder.prototype.ignoreBOM"));
+ }
+
+ [Fact]
+ public void HasTheWebIdlShape()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("new TextEncoder() instanceof TextEncoder").AsBoolean().Should().BeTrue();
+ engine.Evaluate("new TextDecoder() instanceof TextDecoder").AsBoolean().Should().BeTrue();
+ engine.Evaluate("Object.getPrototypeOf(TextEncoder.prototype) === Object.prototype").AsBoolean().Should().BeTrue();
+ engine.Evaluate("TextEncoder.prototype.constructor === TextEncoder").AsBoolean().Should().BeTrue();
+ engine.Evaluate("TextDecoder.prototype.constructor === TextDecoder").AsBoolean().Should().BeTrue();
+ engine.Evaluate("Object.prototype.toString.call(new TextEncoder())").AsString().Should().Be("[object TextEncoder]");
+ engine.Evaluate("Object.prototype.toString.call(new TextDecoder())").AsString().Should().Be("[object TextDecoder]");
+
+ // An instance has no own property: everything is an accessor or an operation on the prototype.
+ engine.Evaluate("Object.getOwnPropertyNames(new TextEncoder()).length").AsNumber().Should().Be(0);
+ engine.Evaluate("Object.getOwnPropertyNames(new TextDecoder()).length").AsNumber().Should().Be(0);
+ }
+
+ [Fact]
+ public void HasTheArityTheIdlDeclares()
+ {
+ var engine = WebEngine();
+
+ // Optional arguments do not count, so only encodeInto's two required ones do.
+ engine.Evaluate("TextEncoder.prototype.encode.length").AsNumber().Should().Be(0);
+ engine.Evaluate("TextEncoder.prototype.encodeInto.length").AsNumber().Should().Be(2);
+ engine.Evaluate("TextDecoder.prototype.decode.length").AsNumber().Should().Be(0);
+ engine.Evaluate("TextDecoder.length").AsNumber().Should().Be(0);
+ }
+
+ [Fact]
+ public void ExposesAttributesAsEnumerableAccessorsAndOperationsAsNonEnumerable()
+ {
+ var engine = WebEngine();
+
+ // WebIDL attributes are enumerable accessor properties on the prototype.
+ engine.Evaluate("""
+ const d = Object.getOwnPropertyDescriptor(TextDecoder.prototype, 'encoding');
+ [typeof d.get, typeof d.set, d.enumerable, d.configurable].join('|');
+ """).AsString().Should().Be("function|undefined|true|true");
+
+ // The operations are non-enumerable, which is Jint's one documented simplification here: WebIDL
+ // makes them enumerable, and the source generator gives every built-in method the ECMAScript
+ // attributes instead.
+ engine.Evaluate("Object.getOwnPropertyDescriptor(TextDecoder.prototype, 'decode').enumerable").AsBoolean().Should().BeFalse();
+ engine.Evaluate("Object.getOwnPropertyDescriptor(TextEncoder.prototype, 'encode').writable").AsBoolean().Should().BeTrue();
+
+ // @@toStringTag is configurable only, as the specification gives it.
+ engine.Evaluate("""
+ const t = Object.getOwnPropertyDescriptor(TextEncoder.prototype, Symbol.toStringTag);
+ [t.value, t.writable, t.enumerable, t.configurable].join('|');
+ """).AsString().Should().Be("TextEncoder|false|false|true");
+ }
+
+ [Fact]
+ public void SubclassingKeepsThePrototypeTheSubclassAsksFor()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ class MyEncoder extends TextEncoder { }
+ const e = new MyEncoder();
+ [e instanceof MyEncoder, e instanceof TextEncoder, e.encoding].join('|');
+ """).AsString().Should().Be("true|true|utf-8");
+ }
+
+ [Fact]
+ public void RoundTripsThroughEncodeAndDecode()
+ {
+ var engine = WebEngine();
+
+ engine.Evaluate("""
+ const text = 'Grüße, 世界! 😀';
+ new TextDecoder().decode(new TextEncoder().encode(text)) === text;
+ """).AsBoolean().Should().BeTrue();
+ }
+
+ #endregion
+}
+#endif
diff --git a/Jint/Options.WebApi.cs b/Jint/Options.WebApi.cs
index 2a3919ca0a..6e3624fa65 100644
--- a/Jint/Options.WebApi.cs
+++ b/Jint/Options.WebApi.cs
@@ -135,7 +135,6 @@ public class TimerOptions
///
/// The bit layout is fixed ahead of the implementations so that a value persisted by a host keeps its meaning
/// as the surface grows. The bits reserved for the features still to land are
-/// Encoding = 1 << 2, Base64 = 1 << 3,
/// StructuredClone = 1 << 4, Crypto = 1 << 5, Performance = 1 << 6,
/// Events = 1 << 7, Url = 1 << 8, Files = 1 << 9 and
/// Fetch = 1 << 10. A flag is declared here only once the feature behind it actually exists, so
@@ -169,11 +168,23 @@ public enum WebApiFeatures
///
Timers = 1 << 1,
+ ///
+ /// TextEncoder and TextDecoder — https://encoding.spec.whatwg.org/#api. The encoder is
+ /// UTF-8 only, as the standard requires; the decoder reads UTF-8, UTF-16LE and UTF-16BE.
+ ///
+ Encoding = 1 << 2,
+
+ ///
+ /// The atob and btoa functions —
+ /// https://html.spec.whatwg.org/multipage/webappapis.html#atob.
+ ///
+ Base64 = 1 << 3,
+
///
/// The web APIs a host normally wants: everything except outbound network access. Today that is
- /// and ; it grows as further features land, and never comes to
- /// include fetch.
+ /// , , and ; it grows
+ /// as further features land, and never comes to include fetch.
///
- Default = Console | Timers,
+ Default = Console | Timers | Encoding | Base64,
}
#endif
diff --git a/Jint/Runtime/Intrinsics.WebApi.cs b/Jint/Runtime/Intrinsics.WebApi.cs
index 62b873f171..1e807446a3 100644
--- a/Jint/Runtime/Intrinsics.WebApi.cs
+++ b/Jint/Runtime/Intrinsics.WebApi.cs
@@ -1,7 +1,9 @@
#if NET8_0_OR_GREATER
+using Jint.WebApi.Base64;
using Jint.WebApi.Console;
using Jint.WebApi.DomException;
using Jint.WebApi.Timers;
+using Jint.WebApi.Encoding;
namespace Jint.Runtime;
@@ -20,6 +22,10 @@ public sealed partial class Intrinsics
private DomExceptionConstructor? _domException;
private ConsoleInstance? _console;
private TimerFunctions? _timers;
+ private TextEncoderConstructor? _textEncoder;
+ private TextDecoderConstructor? _textDecoder;
+ private Base64Function? _atob;
+ private Base64Function? _btoa;
internal DomExceptionConstructor DomException =>
_domException ??= new DomExceptionConstructor(_engine, _realm, Function.PrototypeObject, Error.PrototypeObject);
@@ -33,5 +39,16 @@ public sealed partial class Intrinsics
///
internal TimerFunctions Timers =>
_timers ??= TimerFunctions.Create(_engine, _realm);
+ internal TextEncoderConstructor TextEncoder =>
+ _textEncoder ??= new TextEncoderConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject);
+
+ internal TextDecoderConstructor TextDecoder =>
+ _textDecoder ??= new TextDecoderConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject);
+
+ internal Base64Function Atob =>
+ _atob ??= new Base64Function(_engine, _realm, Function.PrototypeObject, isAtob: true);
+
+ internal Base64Function Btoa =>
+ _btoa ??= new Base64Function(_engine, _realm, Function.PrototypeObject, isAtob: false);
}
#endif
diff --git a/Jint/WebApi/Base64/Base64Function.cs b/Jint/WebApi/Base64/Base64Function.cs
new file mode 100644
index 0000000000..204b62ed38
--- /dev/null
+++ b/Jint/WebApi/Base64/Base64Function.cs
@@ -0,0 +1,110 @@
+#if NET8_0_OR_GREATER
+using System.Diagnostics.CodeAnalysis;
+using Jint.Native;
+using Jint.Native.Function;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+using Jint.WebApi.DomException;
+
+namespace Jint.WebApi.Base64;
+
+///
+/// The global atob and btoa functions.
+///
+/// https://html.spec.whatwg.org/multipage/webappapis.html#atob
+///
+///
+///
+///
+/// They are the two operations of HTML's WindowOrWorkerGlobalScope mixin that have nothing to do
+/// with a window, which is why they belong on any global at all. One type serves both because the pair is
+/// symmetric and each is a single call: two function objects per realm, built only if a script names one.
+///
+///
+/// btoa(data) takes a DOMString, not a USVString: a lone surrogate is not silently
+/// replaced by U+FFFD, it is a code point above U+00FF like any other and raises an
+/// InvalidCharacterError. atob returns a ByteString, so every code unit of the result
+/// is in the range U+0000 to U+00FF.
+///
+///
+internal sealed class Base64Function : Native.Function.Function
+{
+ private static readonly JsString _atobName = new("atob");
+ private static readonly JsString _btoaName = new("btoa");
+
+ private readonly bool _isAtob;
+
+ internal Base64Function(Engine engine, Realm realm, FunctionPrototype functionPrototype, bool isAtob)
+ : base(engine, realm, isAtob ? _atobName : _btoaName)
+ {
+ _isAtob = isAtob;
+ _prototype = functionPrototype;
+ _length = new PropertyDescriptor(JsNumber.PositiveOne, PropertyFlag.Configurable);
+ }
+
+ protected internal override JsValue Call(JsValue thisObject, JsCallArguments arguments)
+ {
+ var data = arguments.At(0);
+ return _isAtob ? Atob(data) : Btoa(data);
+ }
+
+ ///
+ /// https://html.spec.whatwg.org/multipage/webappapis.html#dom-atob — "forgiving-base64 decode", and an
+ /// InvalidCharacterError for its failure.
+ ///
+ private JsString Atob(JsValue data)
+ {
+ var text = TypeConverter.ToString(data);
+
+ if (!ForgivingBase64.TryDecode(text, out var bytes))
+ {
+ ThrowInvalidCharacterError("The string to be decoded is not correctly encoded");
+ return null!;
+ }
+
+ if (bytes.Length == 0)
+ {
+ return JsString.Empty;
+ }
+
+ // A ByteString: one code unit per byte, so Latin-1 is the encoding by definition rather than by
+ // choice.
+ return JsString.Create(System.Text.Encoding.Latin1.GetString(bytes));
+ }
+
+ ///
+ /// https://html.spec.whatwg.org/multipage/webappapis.html#dom-btoa — one byte per code unit, then
+ /// "forgiving-base64 encode", which is plain RFC 4648 base64.
+ ///
+ private JsString Btoa(JsValue data)
+ {
+ var text = TypeConverter.ToString(data);
+
+ var bytes = new byte[text.Length];
+ for (var i = 0; i < text.Length; i++)
+ {
+ var c = text[i];
+ if (c > 0xFF)
+ {
+ ThrowInvalidCharacterError("The string to be encoded contains characters outside of the Latin1 range");
+ }
+
+ bytes[i] = (byte) c;
+ }
+
+ return JsString.Create(Convert.ToBase64String(bytes));
+ }
+
+ ///
+ /// Raises the "InvalidCharacterError" DOMException both operations report their one
+ /// failure with. It is thrown as the error value, so script catches a real DOMException
+ /// and can read its name and code.
+ ///
+ [DoesNotReturn]
+ private void ThrowInvalidCharacterError(string message)
+ {
+ var error = _realm.Intrinsics.DomException.CreateException(DomExceptionNames.InvalidCharacter, message);
+ Throw.JavaScriptException(_engine, error, _engine.GetLastSyntaxElement()?.Location ?? default);
+ }
+}
+#endif
diff --git a/Jint/WebApi/Base64/ForgivingBase64.cs b/Jint/WebApi/Base64/ForgivingBase64.cs
new file mode 100644
index 0000000000..8591061d0b
--- /dev/null
+++ b/Jint/WebApi/Base64/ForgivingBase64.cs
@@ -0,0 +1,167 @@
+#if NET8_0_OR_GREATER
+using System.Diagnostics.CodeAnalysis;
+
+namespace Jint.WebApi.Base64;
+
+///
+/// "Forgiving-base64 decode", https://infra.spec.whatwg.org/#forgiving-base64-decode — the algorithm
+/// behind atob.
+///
+///
+///
+/// It is deliberately hand-written rather than delegated to , which
+/// is not the same function: Convert accepts only whitespace it happens to skip, insists on
+/// padding to a multiple of four, and rejects several inputs the algorithm accepts — "YQ" and
+/// "YWJj\ndA==" both decode here and both throw there. The differences run in the other direction
+/// too, so neither "try Convert first" nor "clean the string up and hand it to Convert" is correct.
+///
+///
+/// The padding rule is the subtle part and is exactly as written: trailing = is removed only
+/// when the whitespace-stripped length is already a multiple of four, and at most two are removed. So
+/// "YQ==" decodes, while "YQ=" (length 3) keeps its =, fails the alphabet check and
+/// is a failure — which is what a browser does.
+///
+///
+internal static class ForgivingBase64
+{
+ ///
+ /// Decodes , or reports the specification's "failure", which atob turns
+ /// into an InvalidCharacterError.
+ ///
+ internal static bool TryDecode(string data, [NotNullWhen(true)] out byte[]? output)
+ {
+ output = null;
+
+ // Step 1, and the part of steps 2 and 3 that needs the whitespace-stripped length.
+ var length = 0;
+ foreach (var c in data)
+ {
+ if (!IsAsciiWhitespace(c))
+ {
+ length++;
+ }
+ }
+
+ // Step 2: only a length that already divides by four sheds its padding, and never more than two
+ // code points of it.
+ if (length % 4 == 0)
+ {
+ var padding = 0;
+ for (var i = data.Length - 1; i >= 0 && padding < 2; i--)
+ {
+ var c = data[i];
+ if (IsAsciiWhitespace(c))
+ {
+ continue;
+ }
+
+ if (c != '=')
+ {
+ break;
+ }
+
+ padding++;
+ }
+
+ length -= padding;
+ }
+
+ // Step 3.
+ if (length % 4 == 1)
+ {
+ return false;
+ }
+
+ var result = new byte[length / 4 * 3 + (length % 4) * 3 / 4];
+
+ var buffer = 0;
+ var bits = 0;
+ var written = 0;
+ var consumed = 0;
+ foreach (var c in data)
+ {
+ if (IsAsciiWhitespace(c))
+ {
+ continue;
+ }
+
+ if (consumed == length)
+ {
+ // Whatever is left can only be the one or two '=' step 2 removed: any other trailing code
+ // point would have left the length unchanged and been validated below.
+ break;
+ }
+
+ consumed++;
+
+ // Step 4, checked per code point as it is consumed rather than in a separate pass.
+ var sextet = Sextet(c);
+ if (sextet < 0)
+ {
+ return false;
+ }
+
+ buffer = (buffer << 6) | sextet;
+ bits += 6;
+ if (bits == 24)
+ {
+ result[written++] = (byte) (buffer >> 16);
+ result[written++] = (byte) (buffer >> 8);
+ result[written++] = (byte) buffer;
+ buffer = 0;
+ bits = 0;
+ }
+ }
+
+ // Step 9: 12 bits carry one byte (the last four are discarded), 18 bits carry two (the last two
+ // are). Six is impossible, because step 3 refused a length that leaves a remainder of one.
+ if (bits == 12)
+ {
+ result[written] = (byte) (buffer >> 4);
+ }
+ else if (bits == 18)
+ {
+ result[written++] = (byte) (buffer >> 10);
+ result[written] = (byte) (buffer >> 2);
+ }
+
+ output = result;
+ return true;
+ }
+
+ ///
+ /// The value of a code point in the base 64 alphabet of RFC 4648 table 1, or -1 for anything
+ /// outside it — U+002B (+), U+002F (/) and ASCII alphanumeric, which is step 4's list exactly.
+ ///
+ private static int Sextet(char c)
+ {
+ if (char.IsAsciiLetterUpper(c))
+ {
+ return c - 'A';
+ }
+
+ if (char.IsAsciiLetterLower(c))
+ {
+ return c - 'a' + 26;
+ }
+
+ if (char.IsAsciiDigit(c))
+ {
+ return c - '0' + 52;
+ }
+
+ return c switch
+ {
+ '+' => 62,
+ '/' => 63,
+ _ => -1,
+ };
+ }
+
+ ///
+ /// https://infra.spec.whatwg.org/#ascii-whitespace — TAB, LF, FF, CR and SPACE. U+000B VT is not one,
+ /// so it is a decode failure rather than something to skip.
+ ///
+ private static bool IsAsciiWhitespace(char c) => c is '\t' or '\n' or '\f' or '\r' or ' ';
+}
+#endif
diff --git a/Jint/WebApi/Encoding/BufferSource.cs b/Jint/WebApi/Encoding/BufferSource.cs
new file mode 100644
index 0000000000..723789e8e5
--- /dev/null
+++ b/Jint/WebApi/Encoding/BufferSource.cs
@@ -0,0 +1,76 @@
+#if NET8_0_OR_GREATER
+using Jint.Native;
+using Jint.Native.TypedArray;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// Reads the bytes behind a WebIDL AllowSharedBufferSource — an ArrayBuffer, a
+/// SharedArrayBuffer, or any view over one (a typed array or a DataView).
+///
+/// https://webidl.spec.whatwg.org/#AllowSharedBufferSource
+///
+///
+///
+/// The span returned is a window onto the engine's own backing array, not a copy: the specification's
+/// "get a copy of the buffer source" exists so that a later mutation cannot be observed, and every caller
+/// here consumes the bytes synchronously before returning to script, so the copy would be pure cost.
+///
+/// A detached buffer yields the empty byte sequence rather than an error, which is what
+/// https://webidl.spec.whatwg.org/#dfn-get-buffer-source-copy step 7 says. A view left hanging outside
+/// its resized buffer is clamped to what is still in range, which is the same answer with the same
+/// reasoning — the bytes are gone, so there are none to hand over.
+///
+///
+internal static class BufferSource
+{
+ ///
+ /// Yields the bytes of , or when it is not a buffer
+ /// source at all — which the caller reports as the TypeError the WebIDL conversion would raise.
+ ///
+ internal static bool TryGetBytes(JsValue value, out ReadOnlySpan bytes)
+ {
+ bytes = default;
+
+ if (value is JsTypedArray typedArray)
+ {
+ // Length is the buffer-witness answer, so a length-tracking view follows a resize and an
+ // out-of-bounds one reports zero.
+ var elements = (int) typedArray.Length * typedArray._arrayElementType.GetElementSize();
+ bytes = Window(typedArray._viewedArrayBuffer.ArrayBufferData, typedArray._byteOffset, elements);
+ return true;
+ }
+
+ if (value is JsDataView dataView)
+ {
+ var data = dataView._viewedArrayBuffer?.ArrayBufferData;
+ var offset = (int) dataView._byteOffset;
+ var length = dataView._byteLength == JsTypedArray.LengthAuto
+ ? (data?.Length ?? 0) - offset
+ : (int) dataView._byteLength;
+
+ bytes = Window(data, offset, length);
+ return true;
+ }
+
+ // JsSharedArrayBuffer derives from JsArrayBuffer, which is what AllowShared means here.
+ if (value is JsArrayBuffer arrayBuffer)
+ {
+ bytes = Window(arrayBuffer.ArrayBufferData, 0, arrayBuffer.ArrayBufferByteLength);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static ReadOnlySpan Window(byte[]? data, int offset, int length)
+ {
+ if (data is null || (uint) offset >= (uint) data.Length || length <= 0)
+ {
+ return default;
+ }
+
+ return data.AsSpan(offset, Math.Min(length, data.Length - offset));
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/EncodingLabels.cs b/Jint/WebApi/Encoding/EncodingLabels.cs
new file mode 100644
index 0000000000..74c1b882d9
--- /dev/null
+++ b/Jint/WebApi/Encoding/EncodingLabels.cs
@@ -0,0 +1,114 @@
+#if NET8_0_OR_GREATER
+namespace Jint.WebApi.Encoding;
+
+///
+/// The encodings Jint's TextDecoder understands and the labels that name them.
+///
+/// https://encoding.spec.whatwg.org/#names-and-labels
+///
+///
+///
+///
+/// Three of the specification's encodings are implemented — utf-8, utf-16le and
+/// utf-16be — with every label the table gives each of them. The legacy single-byte and
+/// multi-byte encodings (windows-1252, shift_jis, gbk, …) are deliberately out of
+/// scope: each needs its own index table, and a script reaching for one is not the case this surface
+/// exists for. An unimplemented label is reported as a failure, so the constructor raises the
+/// RangeError the specification asks for rather than silently decoding as something else.
+///
+///
+/// The replacement, x-user-defined and UTF-16-BOM-sniffing behaviours that only
+/// matter for those legacy encodings are out of scope with them.
+///
+///
+internal static class EncodingLabels
+{
+ /// The name https://encoding.spec.whatwg.org/#utf-8 gives the encoding, already ASCII-lowercase.
+ internal const string Utf8 = "utf-8";
+
+ /// The name https://encoding.spec.whatwg.org/#utf-16le gives the encoding.
+ internal const string Utf16Le = "utf-16le";
+
+ /// The name https://encoding.spec.whatwg.org/#utf-16be gives the encoding.
+ internal const string Utf16Be = "utf-16be";
+
+ /// "unicode-1-1-utf-8", the longest label in the implemented subset.
+ private const int MaxLabelLength = 17;
+
+ ///
+ /// ASCII whitespace, https://infra.spec.whatwg.org/#ascii-whitespace — note that U+000B VT is not one
+ /// of them, so a label padded with a vertical tab does not match.
+ ///
+ private static ReadOnlySpan AsciiWhitespace => ['\t', '\n', '\f', '\r', ' '];
+
+ ///
+ /// "Get an encoding", https://encoding.spec.whatwg.org/#concept-encoding-get: strip leading and
+ /// trailing ASCII whitespace, then match the rest ASCII case-insensitively against the label table.
+ /// Returns the encoding's name, or for the specification's "failure".
+ ///
+ ///
+ /// The match is ASCII case-insensitive and nothing more, which is why the comparison is spelled out
+ /// rather than handed to : that folds U+017F LATIN
+ /// SMALL LETTER LONG S onto s, so "cſunicode" would be accepted as a label for
+ /// utf-16le. Any non-ASCII code point in the trimmed label makes it fail here, since every
+ /// label in the table is ASCII.
+ ///
+ internal static string? Lookup(string label)
+ {
+ var trimmed = label.AsSpan().Trim(AsciiWhitespace);
+ if (trimmed.Length is 0 or > MaxLabelLength)
+ {
+ return null;
+ }
+
+ Span lowered = stackalloc char[MaxLabelLength];
+ for (var i = 0; i < trimmed.Length; i++)
+ {
+ var c = trimmed[i];
+ if (!char.IsAscii(c))
+ {
+ return null;
+ }
+
+ lowered[i] = char.IsAsciiLetterUpper(c) ? (char) (c + ('a' - 'A')) : c;
+ }
+
+ return Match(lowered.Slice(0, trimmed.Length));
+ }
+
+ private static string? Match(ReadOnlySpan label)
+ {
+ // https://encoding.spec.whatwg.org/#table-encoding-overrides, the UTF-8 row.
+ if (label.SequenceEqual("utf-8")
+ || label.SequenceEqual("utf8")
+ || label.SequenceEqual("unicode-1-1-utf-8")
+ || label.SequenceEqual("unicode11utf8")
+ || label.SequenceEqual("unicode20utf8")
+ || label.SequenceEqual("x-unicode20utf8"))
+ {
+ return Utf8;
+ }
+
+ // The UTF-16LE row. "utf-16" without a suffix is little-endian, which is the one label here that
+ // regularly surprises people.
+ if (label.SequenceEqual("utf-16le")
+ || label.SequenceEqual("utf-16")
+ || label.SequenceEqual("unicode")
+ || label.SequenceEqual("unicodefeff")
+ || label.SequenceEqual("csunicode")
+ || label.SequenceEqual("iso-10646-ucs-2")
+ || label.SequenceEqual("ucs-2"))
+ {
+ return Utf16Le;
+ }
+
+ // The UTF-16BE row.
+ if (label.SequenceEqual("utf-16be") || label.SequenceEqual("unicodefffe"))
+ {
+ return Utf16Be;
+ }
+
+ return null;
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/JsTextDecoder.cs b/Jint/WebApi/Encoding/JsTextDecoder.cs
new file mode 100644
index 0000000000..002cdf0e21
--- /dev/null
+++ b/Jint/WebApi/Encoding/JsTextDecoder.cs
@@ -0,0 +1,143 @@
+#if NET8_0_OR_GREATER
+using System.Text;
+using Jint.Native;
+using Jint.Native.Object;
+using Jint.Runtime;
+using SystemEncoding = System.Text.Encoding;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// A TextDecoder instance, and the decoding state it carries between calls.
+///
+/// https://encoding.spec.whatwg.org/#interface-textdecoder
+///
+///
+///
+///
+/// The specification's decoder state — the encoding's decoder instance, the I/O queue holding the bytes
+/// of an incomplete sequence, the "do not flush" flag and the "BOM seen" flag — maps onto a BCL
+/// plus two booleans. The is what holds the bytes of a
+/// sequence split across two decode(…, { stream: true }) calls, which is why it is created once
+/// per stream and kept until a non-streaming call ends it.
+///
+///
+/// fatal is the BCL's and the default error mode is its
+/// , so the U+FFFD substitution follows the Unicode
+/// "maximal subpart" recommendation that https://encoding.spec.whatwg.org/#error-mode also describes.
+///
+///
+internal sealed class JsTextDecoder : ObjectInstance
+{
+ // One encoding object per (encoding, error mode). They are immutable and thread-safe; only the
+ // Decoder they hand out is stateful, and that one is per instance.
+ private static readonly SystemEncoding _utf8Replacement = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: false);
+ private static readonly SystemEncoding _utf8Fatal = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+ private static readonly SystemEncoding _utf16LeReplacement = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: false);
+ private static readonly SystemEncoding _utf16LeFatal = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true);
+ private static readonly SystemEncoding _utf16BeReplacement = new UnicodeEncoding(bigEndian: true, byteOrderMark: false, throwOnInvalidBytes: false);
+ private static readonly SystemEncoding _utf16BeFatal = new UnicodeEncoding(bigEndian: true, byteOrderMark: false, throwOnInvalidBytes: true);
+
+ /// U+FEFF, the code point "serialize I/O queue" drops when it leads the stream.
+ private const char ByteOrderMark = '\uFEFF';
+
+ private readonly SystemEncoding _encoding;
+ private readonly string _encodingName;
+
+ private Decoder? _decoder;
+ private bool _doNotFlush;
+ private bool _bomSeen;
+
+ internal JsTextDecoder(Engine engine, string encodingName, bool fatal, bool ignoreBom) : base(engine, ObjectClass.Object)
+ {
+ Name = JsString.Create(encodingName);
+ Fatal = fatal;
+ IgnoreBom = ignoreBom;
+ _encodingName = encodingName;
+ _encoding = Resolve(encodingName, fatal);
+ }
+
+ /// The encoding's name, already ASCII-lowercase — https://encoding.spec.whatwg.org/#dom-textdecoder-encoding.
+ internal JsString Name { get; }
+
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-fatal
+ internal bool Fatal { get; }
+
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-ignorebom
+ internal bool IgnoreBom { get; }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-decode, followed by "serialize I/O queue"
+ /// (https://encoding.spec.whatwg.org/#concept-td-serialize) for the BOM handling.
+ ///
+ /// The realm a fatal failure's TypeError belongs to.
+ /// The bytes to push onto the queue; empty when the argument was omitted.
+ /// The stream option, which becomes the new "do not flush" flag.
+ internal JsString Decode(Realm realm, ReadOnlySpan input, bool stream)
+ {
+ // Step 1: a decode that follows a non-streaming one starts a new stream, which is what discarding
+ // the decoder does — the next call builds a fresh one, holding no bytes over and having seen no BOM.
+ if (!_doNotFlush)
+ {
+ _decoder = null;
+ _bomSeen = false;
+ }
+
+ _doNotFlush = stream;
+
+ var decoder = _decoder ??= _encoding.GetDecoder();
+
+ char[] chars;
+ int charCount;
+ try
+ {
+ // GetCharCount does not advance the decoder, so this is the documented two-pass shape rather
+ // than a double decode.
+ chars = new char[decoder.GetCharCount(input, flush: !stream)];
+ charCount = decoder.GetChars(input, chars, flush: !stream);
+ }
+ catch (DecoderFallbackException)
+ {
+ // The instance is reset rather than left holding a decoder whose state after a fallback
+ // exception the BCL does not define, so the next decode starts a clean stream.
+ _decoder = null;
+ _doNotFlush = false;
+ _bomSeen = false;
+ Throw.TypeError(realm, "The encoded data was not valid for encoding " + _encodingName);
+ return null!;
+ }
+
+ var decoded = chars.AsSpan(0, charCount);
+
+ // "Serialize I/O queue" step 2.3: for utf-8, utf-16le and utf-16be — which is all three we
+ // implement — the very first scalar value of the stream is dropped when it is U+FEFF. BOM seen is
+ // set by looking, not by finding one, so at most one leading BOM is ever removed and only once per
+ // stream, which is what makes a BOM split across two chunks work.
+ if (!IgnoreBom && !_bomSeen && decoded.Length > 0)
+ {
+ _bomSeen = true;
+ if (decoded[0] == ByteOrderMark)
+ {
+ decoded = decoded.Slice(1);
+ }
+ }
+
+ return decoded.Length == 0 ? JsString.Empty : JsString.Create(decoded.ToString());
+ }
+
+ private static SystemEncoding Resolve(string encodingName, bool fatal)
+ {
+ if (string.Equals(encodingName, EncodingLabels.Utf16Le, StringComparison.Ordinal))
+ {
+ return fatal ? _utf16LeFatal : _utf16LeReplacement;
+ }
+
+ if (string.Equals(encodingName, EncodingLabels.Utf16Be, StringComparison.Ordinal))
+ {
+ return fatal ? _utf16BeFatal : _utf16BeReplacement;
+ }
+
+ return fatal ? _utf8Fatal : _utf8Replacement;
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/JsTextEncoder.cs b/Jint/WebApi/Encoding/JsTextEncoder.cs
new file mode 100644
index 0000000000..d7c575324c
--- /dev/null
+++ b/Jint/WebApi/Encoding/JsTextEncoder.cs
@@ -0,0 +1,26 @@
+#if NET8_0_OR_GREATER
+using Jint.Native.Object;
+using Jint.Runtime;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// A TextEncoder instance.
+///
+/// https://encoding.spec.whatwg.org/#interface-textencoder
+///
+///
+///
+/// It carries no state at all: TextEncoder encodes UTF-8 and nothing else, and neither
+/// encode nor encodeInto streams, so there is no pending code unit to remember between
+/// calls. The type exists so the prototype has a brand to check — a plain object handed to
+/// TextEncoder.prototype.encode must raise a TypeError, which it cannot if every object
+/// looks like an encoder.
+///
+internal sealed class JsTextEncoder : ObjectInstance
+{
+ internal JsTextEncoder(Engine engine) : base(engine, ObjectClass.Object)
+ {
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/TextDecoderConstructor.cs b/Jint/WebApi/Encoding/TextDecoderConstructor.cs
new file mode 100644
index 0000000000..ef210ea1cf
--- /dev/null
+++ b/Jint/WebApi/Encoding/TextDecoderConstructor.cs
@@ -0,0 +1,93 @@
+#if NET8_0_OR_GREATER
+using Jint.Native;
+using Jint.Native.Function;
+using Jint.Native.Object;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// The TextDecoder interface object.
+///
+/// https://encoding.spec.whatwg.org/#interface-textdecoder
+///
+///
+///
+///
+/// constructor(optional DOMString label = "utf-8", optional TextDecoderOptions options = {}). The
+/// two arguments are converted first and the constructor steps run after, which is the order WebIDL
+/// specifies and the reason a bad label raises its RangeError only once options has
+/// been read — a getter on options runs even when the label is nonsense.
+///
+///
+/// Called without new it raises a TypeError, which already does
+/// for every interface object shape.
+///
+///
+internal sealed class TextDecoderConstructor : Constructor
+{
+ private static readonly JsString _functionName = new("TextDecoder");
+ private static readonly JsString _fatal = new("fatal");
+ private static readonly JsString _ignoreBom = new("ignoreBOM");
+
+ internal TextDecoderConstructor(
+ Engine engine,
+ Realm realm,
+ FunctionPrototype functionPrototype,
+ ObjectPrototype objectPrototype)
+ : base(engine, realm, _functionName)
+ {
+ _prototype = functionPrototype;
+ PrototypeObject = new TextDecoderPrototype(engine, realm, this, objectPrototype);
+ _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable);
+ _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
+ }
+
+ internal TextDecoderPrototype PrototypeObject { get; }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder
+ ///
+ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
+ {
+ var labelArgument = arguments.At(0);
+ var optionsArgument = arguments.At(1);
+
+ // `optional DOMString label = "utf-8"`: an omitted argument and an explicitly passed undefined
+ // both take the default.
+ var label = labelArgument.IsUndefined() ? EncodingLabels.Utf8 : TypeConverter.ToString(labelArgument);
+
+ // The dictionary conversion, https://webidl.spec.whatwg.org/#es-dictionary: undefined and null
+ // are the empty dictionary, anything else that is not an object is a TypeError, and the members
+ // are read in lexicographic order — "fatal" before "ignoreBOM".
+ var fatal = false;
+ var ignoreBom = false;
+ if (!optionsArgument.IsUndefined() && !optionsArgument.IsNull())
+ {
+ if (optionsArgument is not ObjectInstance options)
+ {
+ Throw.TypeError(_realm, "TextDecoder: options must be an object");
+ return null!;
+ }
+
+ fatal = TypeConverter.ToBoolean(options.Get(_fatal));
+ ignoreBom = TypeConverter.ToBoolean(options.Get(_ignoreBom));
+ }
+
+ // Step 1 and 2: get an encoding from the label, and refuse anything the table does not name.
+ var encoding = EncodingLabels.Lookup(label);
+ if (encoding is null)
+ {
+ Throw.RangeError(_realm, "TextDecoder: the encoding label provided ('" + label + "') is invalid");
+ }
+
+ return OrdinaryCreateFromConstructor(
+ newTarget,
+ static intrinsics => intrinsics.TextDecoder.PrototypeObject,
+ static (Engine engine, Realm _, (string Encoding, bool Fatal, bool IgnoreBom) state)
+ => new JsTextDecoder(engine, state.Encoding, state.Fatal, state.IgnoreBom),
+ (Encoding: encoding!, Fatal: fatal, IgnoreBom: ignoreBom));
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/TextDecoderPrototype.cs b/Jint/WebApi/Encoding/TextDecoderPrototype.cs
new file mode 100644
index 0000000000..9b4dbb081c
--- /dev/null
+++ b/Jint/WebApi/Encoding/TextDecoderPrototype.cs
@@ -0,0 +1,135 @@
+#if NET8_0_OR_GREATER
+using Jint.Native;
+using Jint.Native.Object;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// TextDecoder.prototype — the interface prototype object.
+///
+/// https://encoding.spec.whatwg.org/#interface-textdecoder
+///
+///
+///
+///
+/// encoding, fatal and ignoreBOM are the TextDecoderCommon attributes. They
+/// are accessors here rather than own properties of the instance, as WebIDL specifies attributes, and each
+/// brand-checks its receiver — including TextDecoder.prototype itself, which is not a
+/// TextDecoder.
+///
+///
+/// One documented simplification against WebIDL, the same one console makes: decode is
+/// non-enumerable, where https://webidl.spec.whatwg.org/#es-operations gives an interface prototype
+/// object's operations { writable: true, enumerable: true, configurable: true }. The attributes
+/// themselves are enumerable, as WebIDL asks.
+///
+///
+[JsObject(UseShape = true)]
+internal sealed partial class TextDecoderPrototype : Prototype
+{
+ [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)]
+ private readonly TextDecoderConstructor _constructor;
+
+ [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)]
+ private static readonly JsString TextDecoderToStringTag = new("TextDecoder");
+
+ private static readonly JsString _stream = new("stream");
+
+ internal TextDecoderPrototype(
+ Engine engine,
+ Realm realm,
+ TextDecoderConstructor constructor,
+ ObjectPrototype objectPrototype) : base(engine, realm)
+ {
+ _prototype = objectPrototype;
+ _constructor = constructor;
+ }
+
+ protected override void Initialize()
+ {
+ CreateProperties_Generated();
+ CreateSymbols_Generated();
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-encoding
+ ///
+ [JsAccessor("encoding", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)]
+ private JsString EncodingGet(JsValue thisObject)
+ {
+ return Brand(thisObject).Name;
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-fatal
+ ///
+ [JsAccessor("fatal", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)]
+ private JsBoolean FatalGet(JsValue thisObject)
+ {
+ return Brand(thisObject).Fatal ? JsBoolean.True : JsBoolean.False;
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-ignorebom
+ ///
+ [JsAccessor("ignoreBOM", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)]
+ private JsBoolean IgnoreBomGet(JsValue thisObject)
+ {
+ return Brand(thisObject).IgnoreBom ? JsBoolean.True : JsBoolean.False;
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textdecoder-decode
+ ///
+ ///
+ /// input is an AllowSharedBufferSource: an ArrayBuffer, a SharedArrayBuffer
+ /// or any view over one. Omitting it decodes nothing, which is how a stream is flushed
+ /// (decode() with no argument ends the stream and emits whatever a trailing incomplete sequence
+ /// became). Passing null is not the same as omitting it — the IDL type is not nullable — so it
+ /// is a TypeError.
+ ///
+ [JsFunction(Name = "decode", Length = 0)]
+ private JsString Decode(JsValue thisObject, JsValue input, JsValue options)
+ {
+ var decoder = Brand(thisObject);
+
+ var bytes = ReadOnlySpan.Empty;
+ if (!input.IsUndefined() && !BufferSource.TryGetBytes(input, out bytes))
+ {
+ Throw.TypeError(_realm, "TextDecoder.decode: input must be an ArrayBuffer or a view over one");
+ }
+
+ // The TextDecodeOptions dictionary, read the same way TextDecoderOptions is in the constructor.
+ var stream = false;
+ if (!options.IsUndefined() && !options.IsNull())
+ {
+ if (options is not ObjectInstance optionsObject)
+ {
+ Throw.TypeError(_realm, "TextDecoder.decode: options must be an object");
+ return null!;
+ }
+
+ stream = TypeConverter.ToBoolean(optionsObject.Get(_stream));
+ }
+
+ return decoder.Decode(_realm, bytes, stream);
+ }
+
+ ///
+ /// The WebIDL brand check every operation and attribute performs before it converts its arguments: a
+ /// receiver that is not a platform object implementing the interface raises a TypeError.
+ ///
+ private JsTextDecoder Brand(JsValue thisObject)
+ {
+ if (thisObject is JsTextDecoder decoder)
+ {
+ return decoder;
+ }
+
+ Throw.TypeError(_realm, "Illegal invocation: receiver is not a TextDecoder");
+ return null!;
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/TextEncoderConstructor.cs b/Jint/WebApi/Encoding/TextEncoderConstructor.cs
new file mode 100644
index 0000000000..a8ca5def16
--- /dev/null
+++ b/Jint/WebApi/Encoding/TextEncoderConstructor.cs
@@ -0,0 +1,52 @@
+#if NET8_0_OR_GREATER
+using Jint.Native;
+using Jint.Native.Function;
+using Jint.Native.Object;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// The TextEncoder interface object.
+///
+/// https://encoding.spec.whatwg.org/#interface-textencoder
+///
+///
+///
+/// constructor() — it takes no arguments, because the encoder is UTF-8 and only UTF-8:
+/// "the encoding is always UTF-8", https://encoding.spec.whatwg.org/#dom-textencoder. Called without
+/// new it raises a TypeError, which already does for every
+/// interface object shape.
+///
+internal sealed class TextEncoderConstructor : Constructor
+{
+ private static readonly JsString _functionName = new("TextEncoder");
+
+ internal TextEncoderConstructor(
+ Engine engine,
+ Realm realm,
+ FunctionPrototype functionPrototype,
+ ObjectPrototype objectPrototype)
+ : base(engine, realm, _functionName)
+ {
+ _prototype = functionPrototype;
+ PrototypeObject = new TextEncoderPrototype(engine, realm, this, objectPrototype);
+ _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable);
+ _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden);
+ }
+
+ internal TextEncoderPrototype PrototypeObject { get; }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textencoder
+ ///
+ public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget)
+ {
+ return OrdinaryCreateFromConstructor(
+ newTarget,
+ static intrinsics => intrinsics.TextEncoder.PrototypeObject,
+ static (Engine engine, Realm _, object? _) => new JsTextEncoder(engine));
+ }
+}
+#endif
diff --git a/Jint/WebApi/Encoding/TextEncoderPrototype.cs b/Jint/WebApi/Encoding/TextEncoderPrototype.cs
new file mode 100644
index 0000000000..1d1cd4c59d
--- /dev/null
+++ b/Jint/WebApi/Encoding/TextEncoderPrototype.cs
@@ -0,0 +1,177 @@
+#if NET8_0_OR_GREATER
+using System.Text;
+using Jint.Native;
+using Jint.Native.Object;
+using Jint.Native.TypedArray;
+using Jint.Runtime;
+using Jint.Runtime.Descriptors;
+using SystemEncoding = System.Text.Encoding;
+
+namespace Jint.WebApi.Encoding;
+
+///
+/// TextEncoder.prototype — the interface prototype object.
+///
+/// https://encoding.spec.whatwg.org/#interface-textencoder
+///
+///
+///
+///
+/// encoding is the TextEncoderCommon attribute and answers "utf-8" for every
+/// instance, since that is the only encoding TextEncoder has ever supported. It is an accessor
+/// here rather than an own property of the instance, as WebIDL specifies attributes, and brand-checks its
+/// receiver — including TextEncoder.prototype itself, which is not a TextEncoder.
+///
+///
+/// One documented simplification against WebIDL, the same one console makes: encode and
+/// encodeInto are non-enumerable, where https://webidl.spec.whatwg.org/#es-operations gives an
+/// interface prototype object's operations { writable: true, enumerable: true, configurable: true }.
+/// The attributes themselves are enumerable, as WebIDL asks.
+///
+///
+[JsObject(UseShape = true)]
+internal sealed partial class TextEncoderPrototype : Prototype
+{
+ [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)]
+ private readonly TextEncoderConstructor _constructor;
+
+ [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)]
+ private static readonly JsString TextEncoderToStringTag = new("TextEncoder");
+
+ private static readonly JsString _utf8 = new(EncodingLabels.Utf8);
+
+ ///
+ /// TextEncoderEncodeIntoResult, https://encoding.spec.whatwg.org/#dictdef-textencoderencodeintoresult —
+ /// a WebIDL dictionary, so an ordinary object with two data properties in declaration order. Declaring
+ /// it as a layout means every result object in an engine shares one hidden class, so a loop reading
+ /// .written off them keeps a monomorphic inline cache.
+ ///
+ private static readonly JsObjectLayout _encodeIntoResultLayout = JsObjectLayout.CreateBuilder()
+ .Add("read")
+ .Add("written")
+ .Build();
+
+ internal TextEncoderPrototype(
+ Engine engine,
+ Realm realm,
+ TextEncoderConstructor constructor,
+ ObjectPrototype objectPrototype) : base(engine, realm)
+ {
+ _prototype = objectPrototype;
+ _constructor = constructor;
+ }
+
+ protected override void Initialize()
+ {
+ CreateProperties_Generated();
+ CreateSymbols_Generated();
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textencoder-encoding
+ ///
+ [JsAccessor("encoding", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)]
+ private JsString EncodingGet(JsValue thisObject)
+ {
+ Brand(thisObject);
+ return _utf8;
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textencoder-encode
+ ///
+ ///
+ /// The argument is a USVString, so an unpaired surrogate is replaced by U+FFFD rather than
+ /// encoded — which is exactly what .NET's UTF-8 encoder does with its replacement fallback, so the
+ /// conversion and the encoding happen in one pass.
+ ///
+ [JsFunction(Name = "encode", Length = 0)]
+ private JsTypedArray Encode(JsValue thisObject, JsValue input)
+ {
+ Brand(thisObject);
+
+ // `optional USVString input = ""`: an omitted argument and an explicitly passed undefined both
+ // take the default.
+ var text = input.IsUndefined() ? string.Empty : TypeConverter.ToString(input);
+ return _realm.Intrinsics.Uint8Array.Construct(SystemEncoding.UTF8.GetBytes(text));
+ }
+
+ ///
+ /// https://encoding.spec.whatwg.org/#dom-textencoder-encodeinto
+ ///
+ ///
+ /// The loop stops as soon as the next scalar value's UTF-8 sequence would not fit, so a code point is
+ /// never split across the end of the destination. read counts UTF-16 code units consumed, which
+ /// is why a surrogate pair advances it by two while the U+FFFD an unpaired surrogate becomes advances
+ /// it by one.
+ ///
+ [JsFunction(Name = "encodeInto", Length = 2)]
+ private JsObject EncodeInto(JsValue thisObject, JsValue source, JsValue destination)
+ {
+ Brand(thisObject);
+
+ var text = TypeConverter.ToString(source);
+
+ // `[AllowShared] Uint8Array destination` — a Uint8ClampedArray or any other view is a WebIDL
+ // conversion failure, not a silently accepted destination.
+ if (destination is not JsTypedArray { _arrayElementType: TypedArrayElementType.Uint8 } target)
+ {
+ Throw.TypeError(_realm, "TextEncoder.encodeInto: destination must be a Uint8Array");
+ return null!;
+ }
+
+ var buffer = target._viewedArrayBuffer;
+ buffer.AssertNotImmutable();
+
+ var available = Writable(buffer.ArrayBufferData, target._byteOffset, (int) target.Length);
+
+ var read = 0;
+ var written = 0;
+ while (read < text.Length)
+ {
+ // Rune.DecodeFromUtf16 is the specification's "convert code unit to scalar value" exactly:
+ // an unpaired surrogate, leading or trailing, decodes to U+FFFD and consumes one code unit.
+ Rune.DecodeFromUtf16(text.AsSpan(read), out var rune, out var charsConsumed);
+
+ var byteCount = rune.Utf8SequenceLength;
+ if (available.Length - written < byteCount)
+ {
+ break;
+ }
+
+ rune.EncodeToUtf8(available.Slice(written));
+ written += byteCount;
+ read += charsConsumed;
+ }
+
+ return JsObject.Create(_engine, _encodeIntoResultLayout, [JsNumber.Create(read), JsNumber.Create(written)]);
+ }
+
+ ///
+ /// The destination's own window onto its buffer. A detached buffer, and a view left outside a resized
+ /// one, both yield an empty span — which stops the loop before its first write, so the result is
+ /// { read: 0, written: 0 } rather than an exception.
+ ///
+ private static Span Writable(byte[]? data, int byteOffset, int length)
+ {
+ if (data is null || (uint) byteOffset >= (uint) data.Length || length <= 0)
+ {
+ return default;
+ }
+
+ return data.AsSpan(byteOffset, Math.Min(length, data.Length - byteOffset));
+ }
+
+ ///
+ /// The WebIDL brand check every operation and attribute performs before it converts its arguments: a
+ /// receiver that is not a platform object implementing the interface raises a TypeError.
+ ///
+ private void Brand(JsValue thisObject)
+ {
+ if (thisObject is not JsTextEncoder)
+ {
+ Throw.TypeError(_realm, "Illegal invocation: receiver is not a TextEncoder");
+ }
+ }
+}
+#endif
diff --git a/Jint/WebApi/WebApiRegistration.cs b/Jint/WebApi/WebApiRegistration.cs
index 5cae3a5151..ad164e9829 100644
--- a/Jint/WebApi/WebApiRegistration.cs
+++ b/Jint/WebApi/WebApiRegistration.cs
@@ -64,6 +64,20 @@ internal static void Apply(Options options, Engine engine)
Install(global, engine, "clearInterval", static e => e.Realm.Intrinsics.Timers.ClearInterval, PropertyFlag.ConfigurableEnumerableWritable);
Install(global, engine, "queueMicrotask", static e => e.Realm.Intrinsics.Timers.QueueMicrotask, PropertyFlag.ConfigurableEnumerableWritable);
}
+
+ if ((options.WebApi.Features & WebApiFeatures.Encoding) != WebApiFeatures.None)
+ {
+ Install(global, engine, "TextDecoder", static e => e.Realm.Intrinsics.TextDecoder, PropertyFlag.NonEnumerable);
+ Install(global, engine, "TextEncoder", static e => e.Realm.Intrinsics.TextEncoder, PropertyFlag.NonEnumerable);
+ }
+
+ if ((options.WebApi.Features & WebApiFeatures.Base64) != WebApiFeatures.None)
+ {
+ // Operations of a WebIDL interface mixin on the global are enumerable, unlike interface
+ // objects — https://webidl.spec.whatwg.org/#es-operations.
+ Install(global, engine, "atob", static e => e.Realm.Intrinsics.Atob, PropertyFlag.ConfigurableEnumerableWritable);
+ Install(global, engine, "btoa", static e => e.Realm.Intrinsics.Btoa, PropertyFlag.ConfigurableEnumerableWritable);
+ }
}
///
diff --git a/README.md b/README.md
index 1fb2f9da68..7cd817f280 100644
--- a/README.md
+++ b/README.md
@@ -214,8 +214,8 @@ its own `console` (or any other name in the table below), enabling the feature l
| `console` (`log`/`warn`/`error`/`group`/`count`/`time`/`assert`/`trace`/`dir`) | `WebApiFeatures.Console` | ✔ shipped |
| `DOMException` | *(no flag — installed whenever any feature is enabled)* | ✔ shipped |
| `setTimeout` / `setInterval` / `clearTimeout` / `clearInterval` / `queueMicrotask` | `WebApiFeatures.Timers` | ✔ shipped |
-| `TextEncoder` / `TextDecoder` | `Encoding` | in progress |
-| `atob` / `btoa` | `Base64` | in progress |
+| `TextEncoder` / `TextDecoder` (UTF-8, UTF-16LE, UTF-16BE; `fatal`, `ignoreBOM`, streaming) | `Encoding` | ✔ shipped |
+| `atob` / `btoa` | `Base64` | ✔ shipped |
| `structuredClone` | `StructuredClone` | in progress |
| `crypto.getRandomValues` / `crypto.randomUUID` | `Crypto` | in progress |
| `performance.now` / `performance.timeOrigin` | `Performance` | in progress |