From 3e33d463f2fd2ef0ae0d9f232fd03a4f4b1f8a2b Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 20 Aug 2026 19:41:26 +0300 Subject: [PATCH] Add the fetch object model: Headers, Request and Response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The offline half of fetch — everything a host can adopt without granting outbound network access. Behind the new WebApiFeatures.Fetch flag, which UseWebApis() never sets and Default will never include. Headers is the whole class: append/delete/get/getSetCookie/has/set, the iterable declaration's entries/keys/values/forEach/@@iterator over sort-and- combine (distinct lowercased names in byte order, values joined by ", ", Set-Cookie the one name never combined), RFC 9110 token names and header values that may carry no NUL, CR or LF — the request-splitting defence. The list itself is engine-free, so the HTTP half can build one off-thread. Request is the minimal request of the plan's surface v1: method (normalized for the six standard methods, forbidden methods refused), url, headers, redirect, signal, the Body mixin and clone. Response is status/ok/statusText/ headers/url/redirected/type/bodyUsed, text/json/arrayBuffer/bytes/blob/clone and the three statics error/redirect/json. Bodies are byte[]-buffered rather than streamed: every consumer answers an already-resolved promise, bodyUsed flips on the first read, a second read rejects with a TypeError, clone-after-read throws one, and a clone shares the bytes while carrying its own flag. body is therefore always null and says so. A FormData body is a TypeError naming the missing multipart serializer rather than a silently stringified object. Enabling Fetch implies Events, Url and Files, whose interfaces are part of fetch's own surface; the closure is computed in WebApiRegistration so that a host assigning Options.WebApi.Features directly gets it too, and so that the option keeps reading back what the host asked for. Options.FetchOptions arrives with the flag — HttpClient/HttpClientFactory, AllowedSchemes, UrlFilter, MaxResponseBytes, MaxRedirects, Timeout and MaxConcurrentRequests — so the policy surface is reviewable before anything uses it. Co-Authored-By: Claude Fable 5 --- .../WebApiFetchModelTests.cs | 221 +++++++++++ Jint.Tests/Runtime/WebApi/HeadersTests.cs | 245 ++++++++++++ Jint.Tests/Runtime/WebApi/RequestTests.cs | 309 +++++++++++++++ Jint.Tests/Runtime/WebApi/ResponseTests.cs | 282 ++++++++++++++ .../Runtime/WebApi/WebApiRegistrationTests.cs | 49 +++ Jint/Options.WebApi.cs | 167 +++++++- Jint/Runtime/Intrinsics.WebApi.cs | 14 + Jint/WebApi/Fetch/FetchBodyObject.cs | 266 +++++++++++++ Jint/WebApi/Fetch/FetchValues.cs | 125 ++++++ Jint/WebApi/Fetch/HeaderList.cs | 360 ++++++++++++++++++ Jint/WebApi/Fetch/HeadersConstructor.cs | 69 ++++ Jint/WebApi/Fetch/HeadersIteratorPrototype.cs | 99 +++++ Jint/WebApi/Fetch/HeadersPrototype.cs | 229 +++++++++++ Jint/WebApi/Fetch/JsHeaders.cs | 165 ++++++++ Jint/WebApi/Fetch/JsRequest.cs | 63 +++ Jint/WebApi/Fetch/JsResponse.cs | 69 ++++ Jint/WebApi/Fetch/RequestConstructor.cs | 238 ++++++++++++ Jint/WebApi/Fetch/RequestPrototype.cs | 195 ++++++++++ Jint/WebApi/Fetch/ResponseConstructor.cs | 241 ++++++++++++ Jint/WebApi/Fetch/ResponsePrototype.cs | 202 ++++++++++ Jint/WebApi/Files/FileApi.cs | 58 ++- Jint/WebApi/WebApiOptionsExtensions.cs | 43 +++ Jint/WebApi/WebApiRegistration.cs | 40 +- 23 files changed, 3730 insertions(+), 19 deletions(-) create mode 100644 Jint.Tests.PublicInterface/WebApiFetchModelTests.cs create mode 100644 Jint.Tests/Runtime/WebApi/HeadersTests.cs create mode 100644 Jint.Tests/Runtime/WebApi/RequestTests.cs create mode 100644 Jint.Tests/Runtime/WebApi/ResponseTests.cs create mode 100644 Jint/WebApi/Fetch/FetchBodyObject.cs create mode 100644 Jint/WebApi/Fetch/FetchValues.cs create mode 100644 Jint/WebApi/Fetch/HeaderList.cs create mode 100644 Jint/WebApi/Fetch/HeadersConstructor.cs create mode 100644 Jint/WebApi/Fetch/HeadersIteratorPrototype.cs create mode 100644 Jint/WebApi/Fetch/HeadersPrototype.cs create mode 100644 Jint/WebApi/Fetch/JsHeaders.cs create mode 100644 Jint/WebApi/Fetch/JsRequest.cs create mode 100644 Jint/WebApi/Fetch/JsResponse.cs create mode 100644 Jint/WebApi/Fetch/RequestConstructor.cs create mode 100644 Jint/WebApi/Fetch/RequestPrototype.cs create mode 100644 Jint/WebApi/Fetch/ResponseConstructor.cs create mode 100644 Jint/WebApi/Fetch/ResponsePrototype.cs diff --git a/Jint.Tests.PublicInterface/WebApiFetchModelTests.cs b/Jint.Tests.PublicInterface/WebApiFetchModelTests.cs new file mode 100644 index 0000000000..2715d69d59 --- /dev/null +++ b/Jint.Tests.PublicInterface/WebApiFetchModelTests.cs @@ -0,0 +1,221 @@ +#if NET8_0_OR_GREATER +using Jint; +using Jint.WebApi; + +namespace Jint.Tests.PublicInterface; + +/// +/// The fetch object model — Headers, Request and Response — seen from outside the +/// assembly: what a host has to write to get them, what it gets when it writes nothing, and what the +/// Fetch flag brings with it. +/// +/// +/// This project has no InternalsVisibleTo, so everything here is reachable by a third party. The pin +/// that matters most is together with +/// : outbound network access is a decision a host makes explicitly, +/// never one it inherits from asking for "the web APIs". +/// +public class WebApiFetchModelTests +{ + [Fact] + public void ADefaultEngineHasNoFetchGlobals() + { + var engine = new Engine(); + + foreach (var name in new[] { "Headers", "Request", "Response", "fetch" }) + { + engine.Evaluate($"typeof {name}").AsString().Should().Be("undefined"); + engine.Evaluate($"'{name}' in globalThis").AsBoolean().Should().BeFalse(); + } + + // Not even an engine that named the group but no feature. + new Engine(options => options.WebApi.Features = WebApiFeatures.None) + .Evaluate("typeof Response").AsString().Should().Be("undefined"); + + // ... nor one that asked for a different feature. + new Engine(options => options.UseWebApis(WebApiFeatures.Console)) + .Evaluate("typeof Response").AsString().Should().Be("undefined"); + } + + [Fact] + public void UseWebApisNeverEnablesFetch() + { + var engine = new Engine(options => options.UseWebApis()); + + foreach (var name in new[] { "Headers", "Request", "Response", "fetch" }) + { + engine.Evaluate($"typeof {name}").AsString().Should().Be("undefined"); + } + + WebApiFeatures.Default.Should().NotHaveFlag(WebApiFeatures.Fetch); + } + + [Fact] + public void UseFetchInstallsTheObjectModel() + { + var engine = new Engine(options => options.UseFetch()); + + engine.Evaluate("typeof Headers").AsString().Should().Be("function"); + engine.Evaluate("typeof Request").AsString().Should().Be("function"); + engine.Evaluate("typeof Response").AsString().Should().Be("function"); + + // DOMException comes with any feature, because it is how the web APIs report a failure. + engine.Evaluate("typeof DOMException").AsString().Should().Be("function"); + } + + [Fact] + public void UseFetchImpliesEventsUrlAndFiles() + { + // fetch's own surface is built out of them: a Request always has an AbortSignal, its URL is a WHATWG + // URL, and response.blob() answers with a Blob. + var engine = new Engine(options => options.UseFetch()); + + engine.Evaluate("typeof AbortController").AsString().Should().Be("function"); + engine.Evaluate("typeof AbortSignal").AsString().Should().Be("function"); + engine.Evaluate("typeof URL").AsString().Should().Be("function"); + engine.Evaluate("typeof URLSearchParams").AsString().Should().Be("function"); + engine.Evaluate("typeof Blob").AsString().Should().Be("function"); + + // ... and it brings nothing else: console and the timers are still the host's own decision. + engine.Evaluate("typeof console").AsString().Should().Be("undefined"); + engine.Evaluate("typeof setTimeout").AsString().Should().Be("undefined"); + } + + [Fact] + public void UseFetchOnlyRecordsTheFetchFlag() + { + var options = new Options().UseFetch(); + + // The closure is applied when the engine is built, so what the host asked for is what it reads back. + options.WebApi.Features.Should().Be(WebApiFeatures.Fetch); + } + + [Fact] + public void FeatureCallsAccumulateRatherThanReplace() + { + var options = new Options().UseConsole(ConsoleSink.Null).UseFetch(); + + options.WebApi.Features.Should().HaveFlag(WebApiFeatures.Console); + options.WebApi.Features.Should().HaveFlag(WebApiFeatures.Fetch); + } + + [Fact] + public void TheGlobalsCarryTheAttributesWebIdlAsksFor() + { + var engine = new Engine(options => options.UseFetch()); + + foreach (var name in new[] { "Headers", "Request", "Response" }) + { + // https://webidl.spec.whatwg.org/#es-interfaces — an interface object is writable and + // configurable but not enumerable. + 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 AShadowRealmDoesNotGetThem() + { + var engine = new Engine(options => options.UseFetch()); + + engine.Evaluate("new ShadowRealm().evaluate('typeof Response')").AsString().Should().Be("undefined"); + engine.Evaluate("typeof Response").AsString().Should().Be("function"); + } + + [Fact] + public void AHostRegisteredGlobalWins() + { + var marker = new Native.JsString("host's own Response"); + + var engine = new Engine(options => options + .AddLazyGlobal("Response", _ => marker) + .UseFetch()); + + engine.Evaluate("Response").Should().BeSameAs(marker); + + // The rest of the surface is still installed. + engine.Evaluate("typeof Headers").AsString().Should().Be("function"); + } + + [Fact] + public void TheObjectModelIsUsableWithoutAnyNetwork() + { + // Everything here is offline: the object model is exactly the part of fetch a host can adopt without + // granting outbound network access. + var engine = new Engine(options => options.UseFetch()); + + engine.Execute("var r = new Response(JSON.stringify({ a: 1 }), { status: 201, headers: { 'content-type': 'application/json' } });"); + + engine.Evaluate("r.status").AsNumber().Should().Be(201); + engine.Evaluate("r.ok").AsBoolean().Should().BeTrue(); + engine.Evaluate("r.headers.get('Content-Type')").AsString().Should().Be("application/json"); + engine.Evaluate("r.json()").UnwrapIfPromise().AsObject().Get("a").AsNumber().Should().Be(1); + engine.Evaluate("r.bodyUsed").AsBoolean().Should().BeTrue(); + } + + [Fact] + public void FetchOptionsCarryTheDocumentedDefaults() + { + var options = new Options().UseFetch(); + var fetch = options.WebApi.Fetch; + + fetch.HttpClient.Should().BeNull(); + fetch.HttpClientFactory.Should().BeNull(); + fetch.AllowedSchemes.Should().Equal("https", "http"); + fetch.UrlFilter(new Uri("https://example.org/")).Should().BeTrue(); + fetch.MaxResponseBytes.Should().Be(32 * 1024 * 1024); + fetch.MaxRedirects.Should().Be(20); + fetch.Timeout.Should().Be(TimeSpan.FromSeconds(30)); + fetch.MaxConcurrentRequests.Should().Be(10); + } + + [Fact] + public void UseFetchHandsTheSettingsToTheCallback() + { + var options = new Options().UseFetch(f => + { + f.AllowedSchemes.Remove("http"); + f.MaxResponseBytes = 1024; + f.UrlFilter = uri => uri.Host == "example.org"; + }); + + options.WebApi.Fetch.AllowedSchemes.Should().Equal("https"); + options.WebApi.Fetch.MaxResponseBytes.Should().Be(1024); + options.WebApi.Fetch.UrlFilter(new Uri("https://evil.example/")).Should().BeFalse(); + } + + [Fact] + public void OneOptionsInstanceServesSeveralEnginesIndependently() + { + var options = new Options().UseFetch(); + + var first = new Engine(options); + var second = new Engine(options); + + first.Execute("var h = new Headers({ a: '1' });"); + second.Execute("var h = new Headers({ b: '2' });"); + + first.Evaluate("h.has('b')").AsBoolean().Should().BeFalse(); + second.Evaluate("h.has('a')").AsBoolean().Should().BeFalse(); + + // Each engine has its own interface objects, as it has its own of every other intrinsic. + first.Evaluate("Headers").Should().NotBeSameAs(second.Evaluate("Headers")); + } + + [Fact] + public void SurvivesAGlobalSnapshotRestore() + { + var engine = new Engine(options => options.UseFetch()); + var snapshot = engine.Advanced.CaptureGlobalSnapshot(); + + engine.Execute("var r = new Response('hi');"); + engine.Advanced.RestoreGlobalSnapshot(snapshot); + + engine.Evaluate("typeof r").AsString().Should().Be("undefined"); + engine.Evaluate("new Response('hi').text()").UnwrapIfPromise().AsString().Should().Be("hi"); + } +} +#endif diff --git a/Jint.Tests/Runtime/WebApi/HeadersTests.cs b/Jint.Tests/Runtime/WebApi/HeadersTests.cs new file mode 100644 index 0000000000..c31a3bdfd1 --- /dev/null +++ b/Jint.Tests/Runtime/WebApi/HeadersTests.cs @@ -0,0 +1,245 @@ +#if NET8_0_OR_GREATER +#nullable enable + +using Jint.Native; +using Jint.Runtime; + +namespace Jint.Tests.Runtime.WebApi; + +/// +/// The Headers class as the Fetch Standard specifies it — +/// https://fetch.spec.whatwg.org/#headers-class. +/// +public class HeadersTests +{ + private static Engine WebEngine() => new(options => options.UseFetch()); + + private static JsValue Eval(string source) => WebEngine().Evaluate(source); + + [Fact] + public void ConstructsEmptyFromNoArguments() + { + Eval("[...new Headers()].length").AsNumber().Should().Be(0); + + // init is optional with no default value, so an explicit undefined is still "not present". + Eval("[...new Headers(undefined)].length").AsNumber().Should().Be(0); + } + + [Fact] + public void FillsFromAPairSequence() + { + // https://fetch.spec.whatwg.org/#concept-headers-fill, the sequence arm. + Eval("new Headers([['a', '1'], ['b', '2']]).get('a')").AsString().Should().Be("1"); + + // Any iterable of pairs will do, because the union is resolved on @@iterator being callable. + Eval("new Headers(new Map([['a', '1']])).get('a')").AsString().Should().Be("1"); + Eval("new Headers(new Headers({ a: '1' })).get('a')").AsString().Should().Be("1"); + } + + [Fact] + public void RefusesASequenceElementThatIsNotAPair() + { + Assert.Throws(() => Eval("new Headers([['a']])")) + .Message.Should().Contain("name/value pair"); + + Assert.Throws(() => Eval("new Headers([['a', '1', 'x']])")) + .Message.Should().Contain("name/value pair"); + + Assert.Throws(() => Eval("new Headers([1])")) + .Message.Should().Contain("sequence"); + } + + [Fact] + public void FillsFromARecordInOwnPropertyOrder() + { + // https://webidl.spec.whatwg.org/#es-record — own enumerable string-keyed properties, in own-key + // order. Integer-like keys come first, which is observable through iteration order only after the + // sort, so name order is what this checks. + Eval("[...new Headers({ b: '2', a: '1' }).keys()].join(',')").AsString().Should().Be("a,b"); + + // A non-enumerable own property is not a record member. + Eval("[...new Headers(Object.defineProperty({}, 'a', { value: '1' })).keys()].length") + .AsNumber().Should().Be(0); + + // Nor is an inherited one. + Eval("[...new Headers(Object.create({ a: '1' })).keys()].length").AsNumber().Should().Be(0); + } + + [Fact] + public void RefusesAnInitThatIsNotAnObject() + { + Assert.Throws(() => Eval("new Headers('a: 1')")); + Assert.Throws(() => Eval("new Headers(1)")); + } + + [Fact] + public void MatchesNamesCaseInsensitively() + { + var engine = WebEngine(); + engine.Execute("var h = new Headers({ 'Content-Type': 'text/plain' });"); + + engine.Evaluate("h.get('content-TYPE')").AsString().Should().Be("text/plain"); + engine.Evaluate("h.has('CONTENT-TYPE')").AsBoolean().Should().BeTrue(); + + engine.Execute("h.delete('CoNtEnT-tYpE');"); + engine.Evaluate("h.has('content-type')").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void AppendCombinesWhereSetReplaces() + { + // https://fetch.spec.whatwg.org/#concept-header-list-get — values joined by ", ". + Eval("(() => { const h = new Headers(); h.append('a', '1'); h.append('a', '2'); return h.get('a'); })()") + .AsString().Should().Be("1, 2"); + + // set replaces every one of them, and keeps the position the first had. + Eval("(() => { const h = new Headers([['a','1'],['b','2'],['a','3']]); h.set('a', 'x'); return [...h].map(p => p.join(':')).join(','); })()") + .AsString().Should().Be("a:x,b:2"); + } + + [Fact] + public void GetAnswersNullForAnAbsentName() + { + Eval("new Headers().get('a')").Should().Be(JsValue.Null); + } + + [Fact] + public void NormalizesTheValueButRefusesInteriorNewlines() + { + // https://fetch.spec.whatwg.org/#concept-header-value-normalize strips the surrounding whitespace... + Eval("new Headers({ a: ' 1 \\t' }).get('a')").AsString().Should().Be("1"); + Eval("new Headers({ a: '\\r\\n 1 \\r\\n' }).get('a')").AsString().Should().Be("1"); + + // ... and https://fetch.spec.whatwg.org/#concept-header-value refuses what is left carrying one. + // This is the request-splitting defence: a value with a CRLF in it could start a second request. + Assert.Throws(() => Eval("new Headers({ a: '1\\r\\nX-Evil: 2' })")) + .Message.Should().Contain("Invalid value"); + + Assert.Throws(() => Eval("new Headers({ a: '1\\u0000' })")) + .Message.Should().Contain("Invalid value"); + } + + [Fact] + public void RefusesANameThatIsNotAToken() + { + foreach (var name in new[] { "''", "'a b'", "'a:b'", "'a\\r\\nb'", "'ä'" }) + { + Assert.Throws(() => Eval($"new Headers().append({name}, '1')")); + Assert.Throws(() => Eval($"new Headers().get({name})")); + Assert.Throws(() => Eval($"new Headers().has({name})")); + Assert.Throws(() => Eval($"new Headers().delete({name})")); + } + + // Every other tchar is fine, though. + Eval("new Headers().has(\"!#$%&'*+-.^_`|~0aZ\")").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void RefusesACodeUnitAboveByteRange() + { + // https://webidl.spec.whatwg.org/#es-ByteString + Assert.Throws(() => Eval("new Headers().append('a', '\\u0100')")) + .Message.Should().Contain("ByteString"); + } + + [Fact] + public void DeletingAnAbsentNameIsNotAnError() + { + Eval("(() => { new Headers().delete('a'); return 'ok'; })()").AsString().Should().Be("ok"); + } + + [Fact] + public void IteratesSortedLowercasedAndCombined() + { + // https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine + Eval("[...new Headers([['B','2'],['a','1'],['A','3']])].map(p => p.join('=')).join('|')") + .AsString().Should().Be("a=1, 3|b=2"); + + Eval("[...new Headers({ 'X-Y': '1' }).keys()][0]").AsString().Should().Be("x-y"); + } + + [Fact] + public void KeepsEverySetCookieValueSeparate() + { + // The one name the standard never combines, because a cookie value may itself contain a comma. + var engine = WebEngine(); + engine.Execute("var h = new Headers([['set-cookie', 'a=1'], ['set-cookie', 'b=2']]);"); + + engine.Evaluate("h.getSetCookie().join('|')").AsString().Should().Be("a=1|b=2"); + engine.Evaluate("[...h].length").AsNumber().Should().Be(2); + engine.Evaluate("[...h].map(p => p[1]).join('|')").AsString().Should().Be("a=1|b=2"); + + // get still combines it, which is what the standard says get does for every name. + engine.Evaluate("h.get('set-cookie')").AsString().Should().Be("a=1, b=2"); + + // getSetCookie is empty rather than absent when there is none. + engine.Evaluate("new Headers().getSetCookie().length").AsNumber().Should().Be(0); + } + + [Fact] + public void ExposesTheThreeIteratorsAndForEach() + { + var engine = WebEngine(); + engine.Execute("var h = new Headers([['b','2'],['a','1']]);"); + + engine.Evaluate("[...h.keys()].join(',')").AsString().Should().Be("a,b"); + engine.Evaluate("[...h.values()].join(',')").AsString().Should().Be("1,2"); + engine.Evaluate("[...h.entries()].map(p => p.join('=')).join(',')").AsString().Should().Be("a=1,b=2"); + + // forEach takes the value first and the name second, and the third argument is the object itself. + engine.Evaluate("(() => { const out = []; h.forEach((v, n, o) => out.push(n + '=' + v + ':' + (o === h))); return out.join(','); })()") + .AsString().Should().Be("a=1:true,b=2:true"); + } + + [Fact] + public void SymbolIteratorIsTheSameFunctionAsEntries() + { + // https://webidl.spec.whatwg.org/#es-iterable — function identity a script can observe. + Eval("Headers.prototype[Symbol.iterator] === Headers.prototype.entries").AsBoolean().Should().BeTrue(); + } + + [Fact] + public void IterationIsLive() + { + // https://webidl.spec.whatwg.org/#es-default-iterator-object recomputes the pairs on every step, so + // a header appended during iteration is seen. + Eval(@"(() => { + const h = new Headers([['a', '1']]); + const out = []; + for (const [n] of h) { out.push(n); if (n === 'a') h.append('b', '2'); } + return out.join(','); + })()").AsString().Should().Be("a,b"); + } + + [Fact] + public void HasNoOwnPropertiesAndTheRightToStringTag() + { + Eval("Object.getOwnPropertyNames(new Headers()).length").AsNumber().Should().Be(0); + Eval("Object.prototype.toString.call(new Headers())").AsString().Should().Be("[object Headers]"); + } + + [Fact] + public void BrandChecksEveryMember() + { + foreach (var member in new[] { "append('a','1')", "delete('a')", "get('a')", "getSetCookie()", "has('a')", "set('a','1')", "forEach(() => {})", "entries()", "keys()", "values()" }) + { + Assert.Throws(() => Eval($"Headers.prototype.{member}")) + .Message.Should().Contain("Headers"); + } + } + + [Fact] + public void IsConstructibleAsABaseClass() + { + Eval("(() => { class H extends Headers {}; const h = new H([['a','1']]); return h.get('a') + ':' + (h instanceof H); })()") + .AsString().Should().Be("1:true"); + } + + [Fact] + public void RequiresNew() + { + Assert.Throws(() => Eval("Headers()")) + .Message.Should().Contain("requires 'new'"); + } +} +#endif diff --git a/Jint.Tests/Runtime/WebApi/RequestTests.cs b/Jint.Tests/Runtime/WebApi/RequestTests.cs new file mode 100644 index 0000000000..ac0b678766 --- /dev/null +++ b/Jint.Tests/Runtime/WebApi/RequestTests.cs @@ -0,0 +1,309 @@ +#if NET8_0_OR_GREATER +#nullable enable + +using Jint.Native; +using Jint.Runtime; + +namespace Jint.Tests.Runtime.WebApi; + +/// +/// The Request class as the Fetch Standard specifies it — +/// https://fetch.spec.whatwg.org/#request-class — reduced to the members this implementation has. +/// +public class RequestTests +{ + private static Engine WebEngine() => new(options => options.UseFetch()); + + private static JsValue Eval(string source) => WebEngine().Evaluate(source); + + [Fact] + public void DefaultsToAGetOfTheParsedUrl() + { + Eval("new Request('https://example.org').method").AsString().Should().Be("GET"); + + // The URL is the serialization of the parsed record, so it is normalized. + Eval("new Request('https://example.org').url").AsString().Should().Be("https://example.org/"); + Eval("new Request('HTTPS://Example.ORG:443/a/../b?q#f').url").AsString().Should().Be("https://example.org/b?q#f"); + + Eval("new Request('https://example.org').redirect").AsString().Should().Be("follow"); + Eval("new Request('https://example.org').bodyUsed").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void HasNoBaseUrlSoARelativeInputFails() + { + // The specification parses against the entry settings object's API base URL — a document's URL, which + // an embedded engine has none of. + Assert.Throws(() => Eval("new Request('/a')")) + .Message.Should().Contain("Failed to parse URL"); + } + + [Fact] + public void RefusesAUrlCarryingCredentials() + { + // https://fetch.spec.whatwg.org/#dom-request step 5.3. + Assert.Throws(() => Eval("new Request('https://u:p@example.org/')")) + .Message.Should().Contain("credentials"); + } + + [Fact] + public void NormalizesOnlyTheSixStandardMethods() + { + // https://fetch.spec.whatwg.org/#concept-method-normalize + Eval("new Request('https://example.org', { method: 'post' }).method").AsString().Should().Be("POST"); + Eval("new Request('https://example.org', { method: 'DeLeTe' }).method").AsString().Should().Be("DELETE"); + + // patch is not on the list, so its casing survives — exactly as in a browser. + Eval("new Request('https://example.org', { method: 'patch' }).method").AsString().Should().Be("patch"); + Eval("new Request('https://example.org', { method: 'weird' }).method").AsString().Should().Be("weird"); + } + + [Fact] + public void RefusesAMethodThatIsNotATokenOrIsForbidden() + { + Assert.Throws(() => Eval("new Request('https://example.org', { method: 'a b' })")); + Assert.Throws(() => Eval("new Request('https://example.org', { method: '' })")); + + // https://fetch.spec.whatwg.org/#forbidden-method — a script must not open a proxy tunnel. + foreach (var method in new[] { "CONNECT", "trace", "TrAcK" }) + { + Assert.Throws(() => Eval($"new Request('https://example.org', {{ method: '{method}' }})")) + .Message.Should().Contain("not a valid HTTP method"); + } + } + + [Fact] + public void RefusesABodyOnGetAndHead() + { + Assert.Throws(() => Eval("new Request('https://example.org', { body: 'x' })")) + .Message.Should().Contain("cannot have body"); + + Assert.Throws(() => Eval("new Request('https://example.org', { method: 'HEAD', body: 'x' })")); + + // An explicit null body is no body at all, so this is fine. + Eval("new Request('https://example.org', { body: null }).method").AsString().Should().Be("GET"); + } + + [Fact] + public void ExtractsEveryBodyInitAndItsImpliedContentType() + { + // https://fetch.spec.whatwg.org/#concept-bodyinit-extract + var engine = WebEngine(); + engine.Execute("function req(b) { return new Request('https://example.org', { method: 'POST', body: b }); }"); + + engine.Evaluate("req('hi').headers.get('content-type')").AsString().Should().Be("text/plain;charset=UTF-8"); + engine.Evaluate("req('hi').text()").UnwrapIfPromise().AsString().Should().Be("hi"); + + engine.Evaluate("req(new URLSearchParams({ a: '1 2' })).headers.get('content-type')") + .AsString().Should().Be("application/x-www-form-urlencoded;charset=UTF-8"); + engine.Evaluate("req(new URLSearchParams({ a: '1 2' })).text()").UnwrapIfPromise().AsString().Should().Be("a=1+2"); + + engine.Evaluate("req(new Blob(['ab'], { type: 'text/csv' })).headers.get('content-type')") + .AsString().Should().Be("text/csv"); + + // A typeless blob implies no Content-Type at all. + engine.Evaluate("req(new Blob(['ab'])).headers.has('content-type')").AsBoolean().Should().BeFalse(); + + // Nor does a buffer source. + engine.Evaluate("req(new Uint8Array([104, 105])).headers.has('content-type')").AsBoolean().Should().BeFalse(); + engine.Evaluate("req(new Uint8Array([104, 105])).text()").UnwrapIfPromise().AsString().Should().Be("hi"); + engine.Evaluate("req(new Uint8Array([1, 104, 105, 2]).subarray(1, 3)).text()").UnwrapIfPromise().AsString().Should().Be("hi"); + engine.Evaluate("req(new DataView(new Uint8Array([104, 105]).buffer)).text()").UnwrapIfPromise().AsString().Should().Be("hi"); + + // Anything else is a USVString. + engine.Evaluate("req(42).text()").UnwrapIfPromise().AsString().Should().Be("42"); + } + + [Fact] + public void RefusesAFormDataBodyWithAMessageNamingTheGap() + { + Assert.Throws(() => Eval("new Request('https://example.org', { method: 'POST', body: new FormData() })")) + .Message.Should().Contain("multipart/form-data"); + } + + [Fact] + public void CopiesTheBytesOfABufferSourceBody() + { + // A body that could change under the engine after it was set would be a request-smuggling primitive. + Eval(@"(() => { + const bytes = new Uint8Array([104, 105]); + const r = new Request('https://example.org', { method: 'POST', body: bytes }); + bytes[0] = 111; + return r; + })().text()").UnwrapIfPromise().AsString().Should().Be("hi"); + } + + [Fact] + public void AnExplicitContentTypeWins() + { + // The headers are filled before the body's implied type is appended, so an explicit one survives. + Eval("new Request('https://example.org', { method: 'POST', body: 'x', headers: { 'content-type': 'text/csv' } }).headers.get('content-type')") + .AsString().Should().Be("text/csv"); + } + + [Fact] + public void CopiesFromAnotherRequest() + { + var engine = WebEngine(); + engine.Execute("var a = new Request('https://example.org/a', { method: 'POST', body: 'hi', headers: { 'x-a': '1' } });"); + + engine.Evaluate("new Request(a).method").AsString().Should().Be("POST"); + engine.Evaluate("new Request(a).url").AsString().Should().Be("https://example.org/a"); + engine.Evaluate("new Request(a).headers.get('x-a')").AsString().Should().Be("1"); + engine.Evaluate("new Request(a).text()").UnwrapIfPromise().AsString().Should().Be("hi"); + + // Copying does not consume the original. + engine.Evaluate("a.bodyUsed").AsBoolean().Should().BeFalse(); + + // An explicit headers member replaces the copied list wholesale rather than adding to it. + engine.Evaluate("new Request(a, { headers: { 'x-b': '2' } }).headers.has('x-a')").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void RefusesToCopyAnAlreadyReadRequest() + { + var engine = WebEngine(); + engine.Execute("var a = new Request('https://example.org', { method: 'POST', body: 'hi' }); a.text();"); + + engine.Evaluate("a.bodyUsed").AsBoolean().Should().BeTrue(); + Assert.Throws(() => engine.Evaluate("new Request(a)")) + .Message.Should().Contain("already used"); + } + + [Fact] + public void AlwaysHasASignalThatFollowsTheOneGiven() + { + var engine = WebEngine(); + + engine.Evaluate("new Request('https://example.org').signal instanceof AbortSignal").AsBoolean().Should().BeTrue(); + engine.Evaluate("new Request('https://example.org').signal.aborted").AsBoolean().Should().BeFalse(); + + // https://fetch.spec.whatwg.org/#dom-request — "make this's signal follow signal". + engine.Execute("var c = new AbortController(); var r = new Request('https://example.org', { signal: c.signal });"); + engine.Evaluate("r.signal.aborted").AsBoolean().Should().BeFalse(); + engine.Execute("c.abort('gone');"); + engine.Evaluate("r.signal.aborted").AsBoolean().Should().BeTrue(); + engine.Evaluate("r.signal.reason").AsString().Should().Be("gone"); + + // ... and it is a different object, so aborting the request's signal does not abort the controller's. + engine.Evaluate("r.signal === c.signal").AsBoolean().Should().BeFalse(); + + // An already-aborted signal is inherited as aborted. + engine.Evaluate("(() => { const c2 = new AbortController(); c2.abort(); return new Request('https://example.org', { signal: c2.signal }).signal.aborted; })()") + .AsBoolean().Should().BeTrue(); + + // An explicit null means no signal to follow, not a type error. + engine.Evaluate("new Request('https://example.org', { signal: null }).signal.aborted").AsBoolean().Should().BeFalse(); + Assert.Throws(() => engine.Evaluate("new Request('https://example.org', { signal: {} })")) + .Message.Should().Contain("AbortSignal"); + } + + [Fact] + public void ValidatesTheRedirectEnumeration() + { + Eval("new Request('https://example.org', { redirect: 'manual' }).redirect").AsString().Should().Be("manual"); + Eval("new Request('https://example.org', { redirect: 'error' }).redirect").AsString().Should().Be("error"); + + Assert.Throws(() => Eval("new Request('https://example.org', { redirect: 'nope' })")) + .Message.Should().Contain("RequestRedirect"); + } + + [Fact] + public void ReadsInitMembersInLexicographicalOrder() + { + // WebIDL converts a dictionary's members in lexicographical order of their identifiers, which is + // observable when they are getters: body, headers, method, redirect, signal. + Eval(@"(() => { + const seen = []; + const init = {}; + for (const name of ['signal', 'redirect', 'method', 'headers', 'body']) { + Object.defineProperty(init, name, { get() { seen.push(name); return undefined; }, enumerable: true }); + } + new Request('https://example.org', init); + return seen.join(','); + })()").AsString().Should().Be("body,headers,method,redirect,signal"); + } + + [Fact] + public void CloneSharesTheBytesAndNotTheUsedFlag() + { + var engine = WebEngine(); + engine.Execute("var a = new Request('https://example.org', { method: 'POST', body: 'hi' }); var b = a.clone();"); + + engine.Evaluate("a.text()").UnwrapIfPromise().AsString().Should().Be("hi"); + engine.Evaluate("a.bodyUsed").AsBoolean().Should().BeTrue(); + engine.Evaluate("b.bodyUsed").AsBoolean().Should().BeFalse(); + engine.Evaluate("b.text()").UnwrapIfPromise().AsString().Should().Be("hi"); + + // The headers are copied, not shared. + engine.Execute("b.headers.set('x-b', '1');"); + engine.Evaluate("a.headers.has('x-b')").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void CloneThrowsSynchronouslyForAnUsedBody() + { + // clone does not return a promise, so this is the one Body member that throws rather than rejects. + var engine = WebEngine(); + engine.Execute("var a = new Request('https://example.org', { method: 'POST', body: 'hi' }); a.text();"); + + Assert.Throws(() => engine.Evaluate("a.clone()")) + .Message.Should().Contain("already used"); + } + + [Fact] + public void ClonesSignalFollowsTheOriginal() + { + var engine = WebEngine(); + engine.Execute("var c = new AbortController(); var a = new Request('https://example.org', { signal: c.signal }); var b = a.clone();"); + + engine.Execute("c.abort();"); + engine.Evaluate("b.signal.aborted").AsBoolean().Should().BeTrue(); + } + + [Fact] + public void BodyIsAlwaysNullAndBodyUsedTracksConsumption() + { + var engine = WebEngine(); + engine.Execute("var a = new Request('https://example.org', { method: 'POST', body: 'hi' });"); + + // Streams are a follow-up; the attribute is present and answers null, which is a meaningful answer. + engine.Evaluate("a.body").Should().Be(JsValue.Null); + engine.Evaluate("new Request('https://example.org').body").Should().Be(JsValue.Null); + + engine.Evaluate("a.bodyUsed").AsBoolean().Should().BeFalse(); + engine.Evaluate("a.arrayBuffer()").UnwrapIfPromise(); + engine.Evaluate("a.bodyUsed").AsBoolean().Should().BeTrue(); + } + + [Fact] + public void HasNoOwnPropertiesAndTheRightToStringTag() + { + Eval("Object.getOwnPropertyNames(new Request('https://example.org')).length").AsNumber().Should().Be(0); + Eval("Object.prototype.toString.call(new Request('https://example.org'))").AsString().Should().Be("[object Request]"); + } + + [Fact] + public void HasNoFormDataMember() + { + // Absent rather than throwing, because that is what feature detection is written against. + Eval("'formData' in Request.prototype").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void BrandChecksEveryMember() + { + foreach (var member in new[] { "method", "url", "headers", "redirect", "signal", "body", "bodyUsed" }) + { + Assert.Throws(() => Eval($"Request.prototype.{member}")) + .Message.Should().Contain("Request"); + } + + foreach (var member in new[] { "clone()", "text()", "json()", "blob()", "bytes()", "arrayBuffer()" }) + { + Assert.Throws(() => Eval($"Request.prototype.{member}")) + .Message.Should().Contain("Request"); + } + } +} +#endif diff --git a/Jint.Tests/Runtime/WebApi/ResponseTests.cs b/Jint.Tests/Runtime/WebApi/ResponseTests.cs new file mode 100644 index 0000000000..8917eaa7ca --- /dev/null +++ b/Jint.Tests/Runtime/WebApi/ResponseTests.cs @@ -0,0 +1,282 @@ +#if NET8_0_OR_GREATER +#nullable enable + +using Jint.Native; +using Jint.Runtime; + +namespace Jint.Tests.Runtime.WebApi; + +/// +/// The Response class as the Fetch Standard specifies it — +/// https://fetch.spec.whatwg.org/#response-class. +/// +public class ResponseTests +{ + private static Engine WebEngine() => new(options => options.UseFetch()); + + private static JsValue Eval(string source) => WebEngine().Evaluate(source); + + [Fact] + public void DefaultsToAnEmpty200() + { + Eval("new Response().status").AsNumber().Should().Be(200); + Eval("new Response().ok").AsBoolean().Should().BeTrue(); + Eval("new Response().statusText").AsString().Should().Be(""); + Eval("new Response().type").AsString().Should().Be("default"); + Eval("new Response().url").AsString().Should().Be(""); + Eval("new Response().redirected").AsBoolean().Should().BeFalse(); + Eval("new Response().bodyUsed").AsBoolean().Should().BeFalse(); + Eval("[...new Response().headers].length").AsNumber().Should().Be(0); + } + + [Fact] + public void OkIsExactlyTheTwoHundreds() + { + Eval("new Response(null, { status: 200 }).ok").AsBoolean().Should().BeTrue(); + Eval("new Response(null, { status: 299 }).ok").AsBoolean().Should().BeTrue(); + Eval("new Response(null, { status: 300 }).ok").AsBoolean().Should().BeFalse(); + Eval("new Response(null, { status: 404 }).ok").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void RefusesAStatusOutsideTheAllowedRange() + { + foreach (var status in new[] { "199", "600", "0" }) + { + Assert.Throws(() => Eval($"new Response(null, {{ status: {status} }})")) + .Message.Should().Contain("outside the range"); + } + + // The member is an unsigned short, so it wraps modulo 2^16 before the range check. + Eval("new Response(null, { status: 65736 }).status").AsNumber().Should().Be(200); + } + + [Fact] + public void RefusesAStatusTextThatIsNotAReasonPhrase() + { + Eval("new Response(null, { statusText: 'All good' }).statusText").AsString().Should().Be("All good"); + + Assert.Throws(() => Eval("new Response(null, { statusText: 'a\\r\\nb' })")) + .Message.Should().Contain("Invalid status text"); + } + + [Fact] + public void RefusesABodyOnANullBodyStatus() + { + // https://fetch.spec.whatwg.org/#null-body-status + foreach (var status in new[] { "204", "205", "304" }) + { + Assert.Throws(() => Eval($"new Response('x', {{ status: {status} }})")) + .Message.Should().Contain("null body status"); + } + + // A null body is fine on those statuses, obviously. + Eval("new Response(null, { status: 204 }).status").AsNumber().Should().Be(204); + } + + [Fact] + public void ExtractsTheBodyBeforeTheRangeCheck() + { + // https://fetch.spec.whatwg.org/#dom-response steps 3-5: the body is extracted before "initialize a + // response" runs its checks, so the unsupported body is what is reported. + Assert.Throws(() => Eval("new Response(new FormData(), { status: 999 })")) + .Message.Should().Contain("multipart/form-data"); + } + + [Fact] + public void ReadsTheBodyBackInEveryShape() + { + var engine = WebEngine(); + engine.Execute("function res() { return new Response('{\"a\":1}', { headers: { 'content-type': 'application/json' } }); }"); + + engine.Evaluate("res().text()").UnwrapIfPromise().AsString().Should().Be("{\"a\":1}"); + engine.Evaluate("res().json()").UnwrapIfPromise().AsObject().Get("a").AsNumber().Should().Be(1); + engine.Evaluate("res().arrayBuffer()").UnwrapIfPromise().AsObject().Get("byteLength").AsNumber().Should().Be(7); + engine.Evaluate("res().bytes().then(b => b instanceof Uint8Array && b.length === 7 && b[0] === 123)") + .UnwrapIfPromise().AsBoolean().Should().BeTrue(); + + // blob() takes its type from the Content-Type header. + engine.Evaluate("res().blob().then(b => b.type)").UnwrapIfPromise().AsString().Should().Be("application/json"); + engine.Evaluate("new Response('x').blob().then(b => b.type)").UnwrapIfPromise().AsString().Should().Be("text/plain;charset=utf-8"); + } + + [Fact] + public void ConsumingANullBodyIsAlwaysAllowed() + { + // https://fetch.spec.whatwg.org/#body-unusable — only a *non-null* body can be unusable, so a + // bodyless response reads as the empty string any number of times and never flips bodyUsed. + var engine = WebEngine(); + engine.Execute("var r = new Response();"); + + engine.Evaluate("r.text()").UnwrapIfPromise().AsString().Should().Be(""); + engine.Evaluate("r.bodyUsed").AsBoolean().Should().BeFalse(); + engine.Evaluate("r.text()").UnwrapIfPromise().AsString().Should().Be(""); + } + + [Fact] + public void ASecondConsumeRejectsRatherThanThrowing() + { + // https://fetch.spec.whatwg.org/#concept-body-consume-body — "return a promise rejected with a + // TypeError", never a synchronous throw, which is what lets a fetch chain be written without a try. + var engine = WebEngine(); + engine.Execute("var r = new Response('hi');"); + engine.Evaluate("r.text()").UnwrapIfPromise(); + + engine.Evaluate("r.bodyUsed").AsBoolean().Should().BeTrue(); + + // No throw here — the rejection is the answer. + var second = engine.Evaluate("r.text().then(() => 'resolved', e => e.constructor.name + ': ' + e.message)"); + second.UnwrapIfPromise().AsString().Should().Be("TypeError: Body has already been consumed"); + } + + [Fact] + public void MalformedJsonRejectsWithASyntaxError() + { + var engine = WebEngine(); + engine.Execute("var r = new Response('{oops');"); + + engine.Evaluate("r.json().then(() => 'resolved', e => e.constructor.name)") + .UnwrapIfPromise().AsString().Should().Be("SyntaxError"); + + // The body still counts as consumed, exactly as a successful parse would leave it. + engine.Evaluate("r.bodyUsed").AsBoolean().Should().BeTrue(); + } + + [Fact] + public void CloneSharesTheBytesAndNotTheUsedFlag() + { + var engine = WebEngine(); + engine.Execute("var a = new Response('hi', { status: 201, statusText: 'Made', headers: { 'x-a': '1' } }); var b = a.clone();"); + + engine.Evaluate("b.status").AsNumber().Should().Be(201); + engine.Evaluate("b.statusText").AsString().Should().Be("Made"); + engine.Evaluate("b.headers.get('x-a')").AsString().Should().Be("1"); + + engine.Evaluate("a.text()").UnwrapIfPromise().AsString().Should().Be("hi"); + engine.Evaluate("a.bodyUsed").AsBoolean().Should().BeTrue(); + engine.Evaluate("b.bodyUsed").AsBoolean().Should().BeFalse(); + engine.Evaluate("b.text()").UnwrapIfPromise().AsString().Should().Be("hi"); + + // The headers are copied, not shared. + engine.Execute("b.headers.set('x-b', '2');"); + engine.Evaluate("a.headers.has('x-b')").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void CloneThrowsSynchronouslyForAnUsedBody() + { + var engine = WebEngine(); + engine.Execute("var r = new Response('hi'); r.text();"); + + Assert.Throws(() => engine.Evaluate("r.clone()")) + .Message.Should().Contain("already used"); + } + + [Fact] + public void ErrorIsAnImmutableZeroStatusResponse() + { + // https://fetch.spec.whatwg.org/#dom-response-error + var engine = WebEngine(); + engine.Execute("var r = Response.error();"); + + engine.Evaluate("r.type").AsString().Should().Be("error"); + engine.Evaluate("r.status").AsNumber().Should().Be(0); + engine.Evaluate("r.ok").AsBoolean().Should().BeFalse(); + engine.Evaluate("r.body").Should().Be(JsValue.Null); + + Assert.Throws(() => engine.Evaluate("r.headers.set('a', '1')")) + .Message.Should().Contain("immutable"); + Assert.Throws(() => engine.Evaluate("r.headers.append('a', '1')")) + .Message.Should().Contain("immutable"); + Assert.Throws(() => engine.Evaluate("r.headers.delete('a')")) + .Message.Should().Contain("immutable"); + } + + [Fact] + public void RedirectCarriesTheLocationAndRefusesANonRedirectStatus() + { + // https://fetch.spec.whatwg.org/#dom-response-redirect + Eval("Response.redirect('https://example.org/a').status").AsNumber().Should().Be(302); + Eval("Response.redirect('https://example.org/a').headers.get('location')").AsString().Should().Be("https://example.org/a"); + Eval("Response.redirect('https://example.org/a', 301).status").AsNumber().Should().Be(301); + + Assert.Throws(() => Eval("Response.redirect('https://example.org/a', 200)")) + .Message.Should().Contain("Invalid status code"); + + Assert.Throws(() => Eval("Response.redirect('nope')")) + .Message.Should().Contain("Failed to parse URL"); + + // The headers are immutable, so a script cannot rewrite where the redirect points. + Assert.Throws(() => Eval("Response.redirect('https://example.org/a').headers.set('location', 'https://evil.example/')")) + .Message.Should().Contain("immutable"); + } + + [Fact] + public void JsonSerializesAndCarriesTheJsonContentType() + { + // https://fetch.spec.whatwg.org/#dom-response-json + var engine = WebEngine(); + + engine.Evaluate("Response.json({ a: 1 }).headers.get('content-type')").AsString().Should().Be("application/json"); + engine.Evaluate("Response.json({ a: 1 }).text()").UnwrapIfPromise().AsString().Should().Be("{\"a\":1}"); + engine.Evaluate("Response.json({ a: 1 }, { status: 201 }).status").AsNumber().Should().Be(201); + + // An explicit Content-Type in the init wins, because the headers are filled first. + engine.Evaluate("Response.json({}, { headers: { 'content-type': 'application/problem+json' } }).headers.get('content-type')") + .AsString().Should().Be("application/problem+json"); + + // "If result is undefined, throw a TypeError" — https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-a-json-string. + Assert.Throws(() => engine.Evaluate("Response.json(undefined)")) + .Message.Should().Contain("not JSON serializable"); + Assert.Throws(() => engine.Evaluate("Response.json(() => {})")); + + // The body goes through "initialize a response" too, so a null body status is a TypeError here as well. + Assert.Throws(() => engine.Evaluate("Response.json(null, { status: 204 })")) + .Message.Should().Contain("null body status"); + } + + [Fact] + public void HasNoOwnPropertiesAndTheRightToStringTag() + { + Eval("Object.getOwnPropertyNames(new Response()).length").AsNumber().Should().Be(0); + Eval("Object.prototype.toString.call(new Response())").AsString().Should().Be("[object Response]"); + } + + [Fact] + public void HasNoFormDataMember() + { + Eval("'formData' in Response.prototype").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void TheBodyMixinIsPerInterfaceRatherThanShared() + { + // A WebIDL mixin's members are copied onto every interface that includes it, so the two `text` + // functions are different objects — exactly as in a browser. + Eval("Request.prototype.text === Response.prototype.text").AsBoolean().Should().BeFalse(); + } + + [Fact] + public void BrandChecksEveryMember() + { + foreach (var member in new[] { "type", "url", "redirected", "status", "ok", "statusText", "headers", "body", "bodyUsed" }) + { + Assert.Throws(() => Eval($"Response.prototype.{member}")) + .Message.Should().Contain("Response"); + } + + foreach (var member in new[] { "clone()", "text()", "json()", "blob()", "bytes()", "arrayBuffer()" }) + { + Assert.Throws(() => Eval($"Response.prototype.{member}")) + .Message.Should().Contain("Response"); + } + } + + [Fact] + public void IsConstructibleAsABaseClass() + { + Eval("(() => { class R extends Response {}; const r = new R('hi'); return r.status + ':' + (r instanceof R); })()") + .AsString().Should().Be("200:true"); + } +} +#endif diff --git a/Jint.Tests/Runtime/WebApi/WebApiRegistrationTests.cs b/Jint.Tests/Runtime/WebApi/WebApiRegistrationTests.cs index 26790e4bae..cd565f363d 100644 --- a/Jint.Tests/Runtime/WebApi/WebApiRegistrationTests.cs +++ b/Jint.Tests/Runtime/WebApi/WebApiRegistrationTests.cs @@ -124,6 +124,55 @@ public void InstallsTheEventAndAbortInterfaceObjectsBehindTheEventsFlag() } } + [Fact] + public void InstallsTheFetchInterfaceObjectsBehindTheFetchFlag() + { + var engine = new Engine(options => options.UseFetch()); + + foreach (var name in new[] { "Headers", "Request", "Response" }) + { + engine.Evaluate($"typeof {name}").AsString().Should().Be("function"); + + var descriptor = engine.Realm.GlobalObject.GetOwnProperty(name); + descriptor.Should().BeOfType>(); + descriptor.Enumerable.Should().BeFalse(); + descriptor.Writable.Should().BeTrue(); + descriptor.Configurable.Should().BeTrue(); + + // ... and only behind the flag: the default set never carries it. + new Engine(options => options.UseWebApis()) + .Evaluate($"typeof {name}").AsString().Should().Be("undefined"); + + // ... and never inside a shadow realm. + engine.Evaluate($"new ShadowRealm().evaluate('typeof {name}')").AsString().Should().Be("undefined"); + } + } + + [Fact] + public void FetchBringsTheFeaturesItsSurfaceIsBuiltFrom() + { + // The closure is computed at install, so it catches a host that assigned Features directly rather + // than calling UseFetch. + foreach (var options in new[] { new Options().UseFetch(), Assigned() }) + { + var engine = new Engine(options); + + engine.Evaluate("typeof AbortController").AsString().Should().Be("function"); + engine.Evaluate("typeof URL").AsString().Should().Be("function"); + engine.Evaluate("typeof Blob").AsString().Should().Be("function"); + + // ... and the option value still reads back exactly what the host asked for. + options.WebApi.Features.Should().Be(WebApiFeatures.Fetch); + } + + static Options Assigned() + { + var options = new Options(); + options.WebApi.Features = WebApiFeatures.Fetch; + return options; + } + } + [Fact] public void LeavesAGlobalTheHostAlreadyOwns() { diff --git a/Jint/Options.WebApi.cs b/Jint/Options.WebApi.cs index ad1f5f0f0f..ea147719c8 100644 --- a/Jint/Options.WebApi.cs +++ b/Jint/Options.WebApi.cs @@ -57,6 +57,147 @@ public class WebApiOptions /// . /// public TimerOptions Timers { get; } = new(); + + /// + /// Settings for fetch, installed when contains + /// — which never does. + /// + public FetchOptions Fetch { get; } = new(); + } + + /// + /// Settings for fetch: which requests it may make, how large an answer it will read and how long it + /// will wait. Requires .NET 8 or higher. + /// + /// + /// + /// Enabling fetch gives the script the host's network position. Anything the host process can + /// reach — an internal service, a cloud metadata endpoint, a database admin port — a script can reach too + /// unless something here says otherwise. The defaults bound the obvious resource questions (size, time, + /// concurrency) and restrict the scheme, but they cannot know which hosts are legitimate: that is + /// what is for, and a deployment exposed to untrusted script wants one. + /// + /// + /// Like every other option group this may be shared by any number of engines, including concurrent ones: + /// nothing on it is engine-affine. Two members carry a thread-safety obligation, because they are called + /// from whichever thread the HTTP stack happens to be on — see and + /// . + /// + /// + public class FetchOptions + { + /// + /// The every request goes through, or + /// to use a lazily-created client Jint shares process-wide. + /// + /// + /// + /// Supplying one is how a host takes control of the transport: a DelegatingHandler is the seam + /// for authentication, per-tenant headers, logging and test doubles, and owning the client is how a + /// host controls its lifetime — the shared default is deliberately never disposed. + /// + /// + /// Jint drives redirects itself so that every hop is re-checked against + /// and ; a client whose handler has AllowAutoRedirect left on would + /// follow them underneath that check, so set it to on a handler you supply. + /// + /// + public System.Net.Http.HttpClient? HttpClient { get; set; } + + /// + /// A per-request source of s, which wins over + /// when both are set. + /// + /// + /// Called on the engine's thread, once per fetch call, before anything is sent — so it may read + /// per-request host state through engine.Advanced.HostDefined, which is how a multi-tenant host + /// hands each tenant its own IHttpClientFactory-managed client. It must not return + /// , and it must not block: it runs while the calling script is suspended. + /// + public Func? HttpClientFactory { get; set; } + + /// + /// The URL schemes a request may use. Defaults to https and http; a request to anything + /// else is refused with a TypeError before a socket is opened. + /// + /// + /// Compared ASCII-case-insensitively against the scheme the WHATWG URL parser produced, which is + /// already lowercased. Emptying the list refuses every request. Read once per request, so a host may + /// change it between evaluations; it is not read on a background thread. + /// + public List AllowedSchemes { get; } = new() { "https", "http" }; + + /// + /// The last word on whether a request may be made. Defaults to allowing everything the scheme list + /// already admitted. + /// + /// + /// + /// Re-run on every redirect hop, which is the point: a filter that only saw the first URL would + /// be defeated by a server answering 302 Location: http://169.254.169.254/. The + /// passed is the absolute URL about to be requested. + /// + /// + /// Must be thread-safe and must not block. The first call is on the engine's thread; the + /// redirect calls are on whichever thread the HTTP stack completed the previous hop on. It must not + /// touch the — the engine is not thread-safe, and the script that started the + /// fetch may be running. + /// + /// + /// Refusing is a TypeError rejection carrying no detail about why, which is deliberate: a + /// message naming the rule would let a script map the host's internal network by probing it. + /// + /// + public Func UrlFilter { get; set; } = static _ => true; + + /// + /// The most bytes a response body may decompress to. Defaults to 32 MiB; a response that exceeds it is + /// abandoned and the promise rejects with a TypeError. + /// + /// + /// Counted after decompression, so a compression bomb is bounded by the number a host actually chose + /// rather than by its compressed size. The bytes are buffered in memory, so this is also the ceiling on + /// what one request can cost the process. + /// + /// means unlimited, and zero or less refuses every body. This is + /// deliberately unlike the execution constraints' saturated sentinels, where + /// LimitMemory(long.MaxValue) removes the constraint: there is no constraint to remove here, and + /// a cap that quietly meant "no cap" would be the more dangerous reading of the two. + /// + /// + public long MaxResponseBytes { get; set; } = 32 * 1024 * 1024; + + /// + /// How many redirects one request may follow before the promise rejects with a TypeError. + /// Defaults to 20, which is what browsers use. + /// + public int MaxRedirects { get; set; } = 20; + + /// + /// How long one fetch may take, from the call to the last byte of the body. Defaults to 30 + /// seconds; exceeding it rejects with a TimeoutError DOMException, the same failure + /// AbortSignal.timeout() produces. + /// + /// + /// Enforced CLR-side, on the request's cancellation token, deliberately: it must fire even for an + /// engine nobody is pumping, so that an abandoned request cannot hold a socket open forever. + /// and any non-positive value mean no timeout, which leaves the + /// underlying 's own timeout as the only bound. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// How many requests one engine may have in flight at once. Defaults to 10; a fetch call that + /// would exceed it rejects with a TypeError rather than queueing. + /// + /// + /// Rejecting rather than queueing is the honest answer: a queue would turn a script's burst into an + /// unbounded backlog of held sockets, and a script that wants to throttle itself can already do so with + /// the promise it gets back. Counted per engine, and a request stops counting when its promise settles + /// or when the engine's globals are restored. is the way to spell + /// effectively unbounded; zero or less refuses every request. + /// + public int MaxConcurrentRequests { get; set; } = 10; } /// @@ -141,9 +282,8 @@ 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 -/// Fetch = 1 << 10. A flag is declared here only once the feature behind it actually exists, so -/// that naming one can never compile into an engine that silently does not have it. +/// as the surface grows. A flag is declared here only once the feature behind it actually exists, so that +/// naming one can never compile into an engine that silently does not have it. /// /// /// grows as each non-network feature lands, and will never include the fetch @@ -226,6 +366,27 @@ public enum WebApiFeatures /// Files = 1 << 9, + /// + /// fetch, Headers, Request and Response — outbound HTTP from script. + /// Never part of : a host asking for "the web APIs" must not inherit the + /// ability to make network requests, so this flag is only ever set by naming it or by calling + /// options.UseFetch(). + /// + /// + /// + /// Implies , and , whose interfaces are part of + /// fetch's own surface rather than optional extras: a Request always has an AbortSignal, its + /// URL is a WHATWG URL, and response.blob() answers with a Blob. The closure is computed + /// when the engine is built, so options.WebApi.Features still reads back exactly what the host + /// asked for — Features == WebApiFeatures.Fetch stays true — while the engine carries all four. + /// + /// + /// Read before enabling this: what a script can reach is the host + /// process's network position, and the defaults bound resources rather than destinations. + /// + /// + Fetch = 1 << 10, + /// /// The web APIs a host normally wants: everything except outbound network access. Today that is /// , , , , diff --git a/Jint/Runtime/Intrinsics.WebApi.cs b/Jint/Runtime/Intrinsics.WebApi.cs index 35b3b12d5d..653f8d91d3 100644 --- a/Jint/Runtime/Intrinsics.WebApi.cs +++ b/Jint/Runtime/Intrinsics.WebApi.cs @@ -6,6 +6,7 @@ using Jint.WebApi.DomException; using Jint.WebApi.Encoding; using Jint.WebApi.Events; +using Jint.WebApi.Fetch; using Jint.WebApi.Files; using Jint.WebApi.StructuredClone; using Jint.WebApi.Performance; @@ -114,6 +115,19 @@ public sealed partial class Intrinsics internal AbortControllerConstructor AbortController => _abortController ??= new AbortControllerConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject, AbortSignal); + private HeadersConstructor? _headers; + private RequestConstructor? _request; + private ResponseConstructor? _response; + + internal HeadersConstructor Headers => + _headers ??= new HeadersConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject); + + internal RequestConstructor Request => + _request ??= new RequestConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject); + + internal ResponseConstructor Response => + _response ??= new ResponseConstructor(_engine, _realm, Function.PrototypeObject, Object.PrototypeObject); + internal CryptoInstance Crypto => _crypto ??= new CryptoInstance(_engine, _realm, Object.PrototypeObject); diff --git a/Jint/WebApi/Fetch/FetchBodyObject.cs b/Jint/WebApi/Fetch/FetchBodyObject.cs new file mode 100644 index 0000000000..31b5b78553 --- /dev/null +++ b/Jint/WebApi/Fetch/FetchBodyObject.cs @@ -0,0 +1,266 @@ +#if NET8_0_OR_GREATER +using SystemEncoding = System.Text.Encoding; +using Jint.Native; +using Jint.Native.Json; +using Jint.Native.Object; +using Jint.Native.Promise; +using Jint.Native.TypedArray; +using Jint.Runtime; +using Jint.WebApi.Files; +using Jint.WebApi.Url; +using Jint.WebApi.Url.Parsing; + +namespace Jint.WebApi.Fetch; + +/// +/// The state the Body mixin gives both Request and Response: +/// https://fetch.spec.whatwg.org/#body-mixin. +/// +/// +/// +/// Bodies are buffered, not streamed. The standard's body is a ReadableStream, which Jint does +/// not have; a body here is a byte array that already exists in full, which is why every consumer answers an +/// already-resolved promise and why body is always (see +/// RequestPrototype.BodyGet). What the standard's disturbed flag buys is kept exactly: +/// flips on the first consume and every later one rejects, so a script that reads a +/// response twice fails the way it would in a browser rather than silently succeeding here and failing there. +/// +/// +/// A is the standard's null body, which is not the same as an empty +/// one: it can be consumed any number of times and never flips . +/// +/// +internal abstract class FetchBodyObject : ObjectInstance +{ + protected FetchBodyObject(Engine engine, JsHeaders headers) : base(engine, ObjectClass.Object) + { + Headers = headers; + } + + /// + /// The headers attribute, which is [SameObject] — one Headers object per request or + /// response, for its whole life. + /// + internal JsHeaders Headers { get; } + + /// + /// https://fetch.spec.whatwg.org/#concept-body — the bytes, or for a null body. + /// A clone shares the array with its original, which a buffered body's immutability makes safe. + /// + internal ReadOnlyMemory? Body { get; set; } + + /// + /// https://fetch.spec.whatwg.org/#dom-body-bodyused — whether the body has been consumed. Per object, so + /// a clone starts unused however often the original was read. + /// + internal bool BodyUsed { get; set; } + + /// + /// https://fetch.spec.whatwg.org/#body-unusable — a non-null body that has already been consumed. What + /// makes a second text() reject and a clone() after one throw. + /// + internal bool IsUnusable => Body is not null && BodyUsed; +} + +/// +/// Which representation a Body consumer answers with. +/// +internal enum BodyConsumeKind +{ + /// arrayBuffer() + ArrayBuffer, + + /// bytes() + Bytes, + + /// blob() + Blob, + + /// json() + Json, + + /// text() + Text, +} + +/// +/// The Body mixin's algorithms: extracting a body from a BodyInit and consuming one. +/// +/// https://fetch.spec.whatwg.org/#body-mixin +/// +/// +internal static class FetchBody +{ + private const string TextPlainType = "text/plain;charset=UTF-8"; + private const string FormUrlEncodedType = "application/x-www-form-urlencoded;charset=UTF-8"; + private const string JsonType = "application/json"; + + /// + /// The extract-a-body algorithm, https://fetch.spec.whatwg.org/#concept-bodyinit-extract — the bytes plus + /// the Content-Type the source implies, which is when it implies none. + /// + /// + /// The union arms are tried in the order the IDL declares them, which is what makes a Blob a blob + /// rather than a stringified object. ReadableStream is absent, and a FormData is refused + /// rather than silently stringified — see . + /// + internal static (ReadOnlyMemory Bytes, string? Type) Extract(Realm realm, JsValue body) + { + switch (body) + { + case JsBlob blob: + return (blob.Data.ToArray(), blob.MediaType.Length == 0 ? null : blob.MediaType); + + case JsFormData: + ThrowFormDataUnsupported(realm); + return default; + + case JsUrlSearchParams parameters: + return (SystemEncoding.UTF8.GetBytes(FormUrlEncoded.Serialize(parameters.List)), FormUrlEncodedType); + } + + if (FileApi.TryGetBufferSourceBytes(body, out var buffered)) + { + // "Set source to a copy of the bytes held by object" — the buffer stays script-writable, and a + // request body that changed under the engine after it was set would be a request-smuggling + // primitive rather than a curiosity. + return (buffered.ToArray(), null); + } + + // The USVString arm: every unpaired surrogate becomes U+FFFD, which is what UTF8.GetBytes does. + return (SystemEncoding.UTF8.GetBytes(UrlValues.ToUsvString(body)), TextPlainType); + } + + /// + /// The Content-Type a JSON body carries, for Response.json(data, init). + /// + internal const string JsonContentType = JsonType; + + /// + /// The tail of the two constructors' body handling: store the extracted bytes, and append the implied + /// Content-Type unless the header list already carries one — steps 39 and 40 of + /// https://fetch.spec.whatwg.org/#dom-request and step 6 of + /// https://fetch.spec.whatwg.org/#initialize-a-response. + /// + internal static void SetBody(FetchBodyObject target, ReadOnlyMemory bytes, string? type) + { + target.Body = bytes; + + if (type is not null && !target.Headers.List.Contains("content-type")) + { + target.Headers.List.Append("content-type", type); + } + } + + /// + /// The FormData arm of BodyInit, which needs a multipart/form-data serializer Jint + /// does not have yet. A TypeError naming the gap beats writing a body that is not what the script + /// asked for. + /// + internal static void ThrowFormDataUnsupported(Realm realm) + { + Throw.TypeError(realm, "A FormData body is not supported yet: Jint has no multipart/form-data serializer. Send a string, a Blob, a BufferSource or a URLSearchParams instead."); + } + + /// + /// The consume-body algorithm, https://fetch.spec.whatwg.org/#concept-body-consume-body — always a + /// promise, and never a synchronous throw: an unusable body is a rejection with a TypeError, + /// which is the whole reason a fetch chain can be written without a try. + /// + internal static JsValue Consume(Engine engine, Realm realm, FetchBodyObject target, BodyConsumeKind kind) + { + var capability = PromiseConstructor.NewPromiseCapability(engine, realm.Intrinsics.Promise); + + if (target.IsUnusable) + { + capability.Reject(realm.Intrinsics.TypeError.Construct("Body has already been consumed")); + return capability.PromiseInstance; + } + + // A null body is not disturbed by being read, so bodyUsed stays false and a second read is fine. + var bytes = target.Body ?? default; + if (target.Body is not null) + { + target.BodyUsed = true; + } + + try + { + capability.Resolve(Package(engine, realm, target, bytes.Span, kind)); + } + catch (JavaScriptException ex) + { + // json() on a malformed body: the parser's own SyntaxError becomes the rejection, which is what + // the standard's "if parsing fails, reject with that exception" asks for. + capability.Reject(ex.Error); + } + + return capability.PromiseInstance; + } + + private static JsValue Package(Engine engine, Realm realm, FetchBodyObject target, ReadOnlySpan bytes, BodyConsumeKind kind) + { + switch (kind) + { + case BodyConsumeKind.ArrayBuffer: + return new JsArrayBuffer(engine, bytes.ToArray()) + { + _prototype = realm.Intrinsics.ArrayBuffer.PrototypeObject, + }; + + case BodyConsumeKind.Bytes: + var uint8Array = realm.Intrinsics.Uint8Array; + var array = uint8Array.AllocateTypedArray(uint8Array, (uint) bytes.Length); + TypedArrayConstructor.FillTypedArrayInstance(array, bytes); + return array; + + case BodyConsumeKind.Blob: + return new JsBlob(engine, bytes.ToArray(), MimeType(target)) + { + _prototype = realm.Intrinsics.Blob.PrototypeObject, + }; + + case BodyConsumeKind.Json: + // The byte overload transcodes and strips one leading BOM, which is the UTF-8 decode the + // standard runs before parsing. It decodes strictly, where the standard's decode would put + // U+FFFD in place of an ill-formed sequence — so a body that is not well-formed UTF-8 is a + // SyntaxError here rather than a parse of the replaced text. Both answers are a rejection + // with a SyntaxError unless the corruption sits inside a JSON string literal. + return new JsonParser(engine).Parse(bytes); + + default: + return JsString.Create(Utf8Decode(bytes)); + } + } + + /// + /// UTF-8 decode, https://encoding.spec.whatwg.org/#utf-8-decode: strip one leading BOM, then decode + /// leniently — an ill-formed sequence becomes U+FFFD rather than raising. + /// + private static string Utf8Decode(ReadOnlySpan bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) + { + bytes = bytes.Slice(3); + } + + return SystemEncoding.UTF8.GetString(bytes); + } + + /// + /// The MIME type a blob() gives its result, https://fetch.spec.whatwg.org/#concept-body-mime-type. + /// + /// + /// The standard parses the Content-Type as a MIME type and serializes it back, which lowercases the + /// essence and normalizes the parameters; this runs the File API's own media-type normalization instead — + /// ASCII-lowercase, and the empty string for anything carrying a non-printable character. The two agree on + /// every well-formed value; a malformed one differs in that the standard would answer the empty string + /// where this keeps the lowercased text. + /// + private static string MimeType(FetchBodyObject target) + { + var contentType = target.Headers.List.Get("content-type"); + return contentType is null ? string.Empty : FileApi.NormalizeMediaType(contentType); + } +} +#endif diff --git a/Jint/WebApi/Fetch/FetchValues.cs b/Jint/WebApi/Fetch/FetchValues.cs new file mode 100644 index 0000000000..150bd220e9 --- /dev/null +++ b/Jint/WebApi/Fetch/FetchValues.cs @@ -0,0 +1,125 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Runtime; + +namespace Jint.WebApi.Fetch; + +/// +/// The value conversions and small grammars the Headers, Request and Response bindings +/// share. +/// +internal static class FetchValues +{ + /// + /// WebIDL's ByteString conversion, https://webidl.spec.whatwg.org/#es-ByteString: ToString, + /// then a TypeError for any code unit above 0x00FF. Every header name, header value, method and + /// status text is a ByteString, so this is the one door they come in through. + /// + internal static string ToByteString(Realm realm, JsValue value) + { + var text = TypeConverter.ToString(value); + for (var i = 0; i < text.Length; i++) + { + if (text[i] > 0xFF) + { + Throw.TypeError( + realm, + $"Cannot convert argument to a ByteString because the character at index {i} has a value of {(int) text[i]}, which is greater than 255"); + } + } + + return text; + } + + /// + /// https://fetch.spec.whatwg.org/#concept-method — a method is an RFC 9110 token. + /// + internal static bool IsMethod(string method) => HeaderList.IsName(method); + + /// + /// https://fetch.spec.whatwg.org/#forbidden-method — CONNECT, TRACE and TRACK, + /// byte-case-insensitively. + /// + /// + /// A browser refuses these because they are the shapes of a proxy tunnel and of a cross-site tracing + /// attack; the reasons survive server-side, where a script that could open a CONNECT tunnel through + /// the host's network position would be a far larger hole than in a browser. So unlike the forbidden + /// header lists — which Jint deliberately does not enforce, see — this + /// one is kept. + /// + internal static bool IsForbiddenMethod(string method) + => method.Equals("CONNECT", StringComparison.OrdinalIgnoreCase) + || method.Equals("TRACE", StringComparison.OrdinalIgnoreCase) + || method.Equals("TRACK", StringComparison.OrdinalIgnoreCase); + + /// + /// https://fetch.spec.whatwg.org/#concept-method-normalize — the six methods whose casing is corrected, + /// and only those: patch stays lowercase, exactly as in a browser. + /// + internal static string NormalizeMethod(string method) + { + foreach (var known in _normalizedMethods) + { + if (method.Equals(known, StringComparison.OrdinalIgnoreCase)) + { + return known; + } + } + + return method; + } + + private static readonly string[] _normalizedMethods = ["DELETE", "GET", "HEAD", "OPTIONS", "POST", "PUT"]; + + /// + /// RFC 9110's reason-phrase production, https://www.rfc-editor.org/rfc/rfc9110#name-status-line — + /// HTAB, SP, VCHAR and obs-text. What Response's statusText is checked against. + /// + internal static bool IsReasonPhrase(string value) + { + foreach (var c in value) + { + var ok = c is '\t' or ' ' || (c >= 0x21 && c <= 0x7E) || (c >= 0x80 && c <= 0xFF); + if (!ok) + { + return false; + } + } + + return true; + } + + /// + /// https://fetch.spec.whatwg.org/#null-body-status — a status a response may not carry a body with. + /// + internal static bool IsNullBodyStatus(int status) => status is 101 or 103 or 204 or 205 or 304; + + /// + /// https://fetch.spec.whatwg.org/#redirect-status + /// + internal static bool IsRedirectStatus(int status) => status is 301 or 302 or 303 or 307 or 308; + + /// + /// WebIDL's unsigned short conversion, https://webidl.spec.whatwg.org/#idl-unsigned-short: truncate + /// towards zero and wrap modulo 2^16. It is what ResponseInit's status and + /// Response.redirect's status are declared as, so { status: 65736 } wraps to 200 + /// rather than being out of range. + /// + internal static int ToUnsignedShort(JsValue value) + { + var number = TypeConverter.ToNumber(value); + if (!double.IsFinite(number)) + { + return 0; + } + + var truncated = System.Math.Truncate(number) % 65536.0; + if (truncated < 0) + { + truncated += 65536.0; + } + + return (int) truncated; + } +} +#endif diff --git a/Jint/WebApi/Fetch/HeaderList.cs b/Jint/WebApi/Fetch/HeaderList.cs new file mode 100644 index 0000000000..cfe9908f3a --- /dev/null +++ b/Jint/WebApi/Fetch/HeaderList.cs @@ -0,0 +1,360 @@ +#if NET8_0_OR_GREATER +using System.Runtime.InteropServices; + +namespace Jint.WebApi.Fetch; + +/// +/// One header of a header list, https://fetch.spec.whatwg.org/#concept-header. +/// +/// +/// The header's name, already byte-lowercased. The Fetch Standard compares names byte-case-insensitively +/// everywhere and its iteration order is over lowercased names, so the original casing is not observable +/// through the Headers API at all; storing the lowercased form is what makes every lookup an ordinal +/// comparison. +/// +/// The header's value, already normalized and validated. +[StructLayout(LayoutKind.Auto)] +internal readonly record struct HeaderEntry(string LowerName, string Value); + +/// +/// A header list's guard, https://fetch.spec.whatwg.org/#concept-headers-guard — which mutations the +/// Headers object in front of the list allows. +/// +/// +/// Only changes behaviour here. The request, request-no-cors and +/// response guards exist in the standard to enforce the forbidden header name and forbidden +/// response header name lists, which are a browser's protection of headers the user agent alone controls +/// (Host, Cookie, Origin, …). Jint runs server-side, where those headers are exactly what +/// a script legitimately needs to set, so the lists are deliberately not enforced — the same choice Node and +/// Deno make. The guards are still tracked, so that the two objects the standard makes immutable +/// (Response.error()'s and Response.redirect()'s headers) really are. +/// +internal enum HeadersGuard +{ + /// A header list nobody restricts — a bare new Headers(). + None, + + /// A Request's headers. + Request, + + /// A Response's headers. + Response, + + /// Refuses every mutation with a TypeError. + Immutable, +} + +/// +/// A header list, https://fetch.spec.whatwg.org/#concept-header-list, and the algorithms the Headers +/// class is defined in terms of. +/// +/// +/// +/// Deliberately engine-free — it mentions no Engine, Realm or JsValue — for the same +/// reason Jint.WebApi.Url.Parsing is: the HTTP half of fetch runs on a thread pool thread, where +/// touching the engine is forbidden, and it still has to read a request's headers and build a response's. +/// Validation failures are reported as a so the caller decides which realm's +/// TypeError to raise. +/// +/// +/// A list, not a dictionary: the order of equally-named headers is observable through getSetCookie and +/// through the value get combines, and header lists are small enough that a linear scan beats a hash. +/// +/// +internal sealed class HeaderList +{ + /// + /// The one header name the Fetch Standard never combines, because a Set-Cookie value may itself + /// contain a comma — https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine. + /// + internal const string SetCookieName = "set-cookie"; + + private readonly List _entries; + + internal HeaderList() + { + _entries = new List(); + } + + private HeaderList(List entries) + { + _entries = entries; + } + + /// The guard of the Headers object in front of this list. + internal HeadersGuard Guard { get; set; } + + /// The headers, in insertion order. + internal List Entries => _entries; + + /// + /// A copy holding the same headers and nothing else — notably not the guard, which every caller of this + /// sets for itself. + /// + internal HeaderList Clone() => new(new List(_entries)); + + /// + /// https://fetch.spec.whatwg.org/#concept-header-list-append — no combining, no replacing: a header list + /// may hold any number of headers with one name. + /// + internal void Append(string name, string value) => _entries.Add(new HeaderEntry(Lowercase(name), value)); + + /// + /// https://fetch.spec.whatwg.org/#concept-header-list-contains + /// + internal bool Contains(string name) + { + var key = Lowercase(name); + foreach (var entry in _entries) + { + if (string.Equals(entry.LowerName, key, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-list-get — every value with that name, separated by + /// 0x2C 0x20 (", "), or when the list holds none. + /// + internal string? Get(string name) + { + var key = Lowercase(name); + + // The overwhelmingly common case is one header with the name, which then needs no builder and no + // copy: the value string itself is the answer. + string? first = null; + List? all = null; + foreach (var entry in _entries) + { + if (!string.Equals(entry.LowerName, key, StringComparison.Ordinal)) + { + continue; + } + + if (first is null) + { + first = entry.Value; + continue; + } + + all ??= new List { first }; + all.Add(entry.Value); + } + + return all is null ? first : string.Join(", ", all); + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-list-delete + /// + internal void Delete(string name) + { + var key = Lowercase(name); + _entries.RemoveAll(entry => string.Equals(entry.LowerName, key, StringComparison.Ordinal)); + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-list-set — the first header with the name takes the new + /// value and the rest are removed, so the header keeps its position in the list. + /// + internal void Set(string name, string value) + { + var key = Lowercase(name); + var seen = false; + + for (var i = 0; i < _entries.Count; i++) + { + if (!string.Equals(_entries[i].LowerName, key, StringComparison.Ordinal)) + { + continue; + } + + if (!seen) + { + seen = true; + _entries[i] = new HeaderEntry(key, value); + continue; + } + + _entries.RemoveAt(i); + i--; + } + + if (!seen) + { + _entries.Add(new HeaderEntry(key, value)); + } + } + + /// + /// Every Set-Cookie value, in list order — https://fetch.spec.whatwg.org/#dom-headers-getsetcookie. + /// + internal List GetSetCookie() + { + var values = new List(); + foreach (var entry in _entries) + { + if (string.Equals(entry.LowerName, SetCookieName, StringComparison.Ordinal)) + { + values.Add(entry.Value); + } + } + + return values; + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-list-sort-and-combine — the pairs every iteration of a + /// Headers object walks: one entry per distinct name in ascending byte order, with the values + /// combined, except for Set-Cookie which contributes one entry per value. + /// + internal List SortAndCombine() + { + if (_entries.Count == 0) + { + return new List(); + } + + var names = new List(); + foreach (var entry in _entries) + { + // List.Contains uses the default equality comparer, which for string is ordinal. + if (!names.Contains(entry.LowerName)) + { + names.Add(entry.LowerName); + } + } + + // Header names are ASCII tokens, so an ordinal sort is the specification's "byte less than". The + // names are distinct, so the sort's stability cannot be observed. + names.Sort(StringComparer.Ordinal); + + var combined = new List(names.Count); + foreach (var name in names) + { + if (string.Equals(name, SetCookieName, StringComparison.Ordinal)) + { + foreach (var entry in _entries) + { + if (string.Equals(entry.LowerName, SetCookieName, StringComparison.Ordinal)) + { + combined.Add(entry); + } + } + + continue; + } + + combined.Add(new HeaderEntry(name, Get(name)!)); + } + + return combined; + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-name — a non-empty sequence of RFC 9110 tchars. + /// + internal static bool IsName(string name) + { + if (name.Length == 0) + { + return false; + } + + foreach (var c in name) + { + if (!IsTokenChar(c)) + { + return false; + } + } + + return true; + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-value — no leading or trailing HTTP tab or space, and no + /// NUL, CR or LF anywhere. The last of those is what stops a script splicing a second request or a second + /// response into the one it asked for. + /// + internal static bool IsValue(string value) + { + if (value.Length != 0 && (IsTabOrSpace(value[0]) || IsTabOrSpace(value[value.Length - 1]))) + { + return false; + } + + foreach (var c in value) + { + if (c is '\0' or '\n' or '\r') + { + return false; + } + } + + return true; + } + + /// + /// https://fetch.spec.whatwg.org/#concept-header-value-normalize — strip leading and trailing HTTP + /// whitespace, which is tab, LF, CR and space. Interior newlines survive this and are then refused by + /// ; only the surrounding ones are forgiven. + /// + internal static string Normalize(string value) + { + var start = 0; + var end = value.Length; + + while (start < end && IsHttpWhitespace(value[start])) + { + start++; + } + + while (end > start && IsHttpWhitespace(value[end - 1])) + { + end--; + } + + return start == 0 && end == value.Length ? value : value.Substring(start, end - start); + } + + /// + /// Byte-lowercasing a header name. Every name reaching this has passed , so it is + /// ASCII and the invariant lowercase is the byte lowercase. + /// + internal static string Lowercase(string name) + { + foreach (var c in name) + { + if (c is >= 'A' and <= 'Z') + { + return name.ToLowerInvariant(); + } + } + + return name; + } + + /// + /// RFC 9110 tchar, https://www.rfc-editor.org/rfc/rfc9110#name-tokens. + /// + private static bool IsTokenChar(char c) => c switch + { + >= 'a' and <= 'z' => true, + >= 'A' and <= 'Z' => true, + >= '0' and <= '9' => true, + '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '-' or '.' or '^' or '_' or '`' or '|' or '~' => true, + _ => false, + }; + + /// https://fetch.spec.whatwg.org/#http-tab-or-space + private static bool IsTabOrSpace(char c) => c is '\t' or ' '; + + /// https://fetch.spec.whatwg.org/#http-whitespace + private static bool IsHttpWhitespace(char c) => c is '\t' or '\n' or '\r' or ' '; +} +#endif diff --git a/Jint/WebApi/Fetch/HeadersConstructor.cs b/Jint/WebApi/Fetch/HeadersConstructor.cs new file mode 100644 index 0000000000..f654a6ba83 --- /dev/null +++ b/Jint/WebApi/Fetch/HeadersConstructor.cs @@ -0,0 +1,69 @@ +#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.Fetch; + +/// +/// The Headers interface object. +/// +/// https://fetch.spec.whatwg.org/#headers-class +/// +/// +/// +/// constructor(optional HeadersInit init). As a WebIDL interface object its [[Prototype]] is +/// %Function.prototype% and calling it without new raises a TypeError, which +/// already does. +/// +internal sealed class HeadersConstructor : Constructor +{ + private static readonly JsString _functionName = new("Headers"); + + internal HeadersConstructor( + Engine engine, + Realm realm, + FunctionPrototype functionPrototype, + ObjectPrototype objectPrototype) + : base(engine, realm, _functionName) + { + _prototype = functionPrototype; + PrototypeObject = new HeadersPrototype(engine, realm, this, objectPrototype); + _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable); + _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); + } + + internal HeadersPrototype PrototypeObject { get; } + + /// + /// https://fetch.spec.whatwg.org/#dom-headers — "the new Headers(init) constructor steps are to set this's + /// guard to 'none' and then fill this with init". + /// + public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) + { + var headers = OrdinaryCreateFromConstructor( + newTarget, + static intrinsics => intrinsics.Headers.PrototypeObject, + static (Engine engine, Realm _, HeaderList? state) => new JsHeaders(engine, state!), + new HeaderList()); + + // init is optional with no default value, so an explicitly passed `undefined` means "not present" + // and the object stays empty — https://webidl.spec.whatwg.org/#es-overloads. + var init = arguments.At(0); + if (!init.IsUndefined()) + { + headers.Fill(_realm, init); + } + + return headers; + } + + /// + /// Builds a Headers object over an existing list, for the Request and Response + /// constructors and for a response the network produced. + /// + internal JsHeaders CreateInstance(HeaderList list) => new(_engine, list) { _prototype = PrototypeObject }; +} +#endif diff --git a/Jint/WebApi/Fetch/HeadersIteratorPrototype.cs b/Jint/WebApi/Fetch/HeadersIteratorPrototype.cs new file mode 100644 index 0000000000..bde6f65b23 --- /dev/null +++ b/Jint/WebApi/Fetch/HeadersIteratorPrototype.cs @@ -0,0 +1,99 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Iterator; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; + +namespace Jint.WebApi.Fetch; + +/// +/// The iterator prototype behind Headers.prototype.entries, keys and values — the object +/// WebIDL calls an "iterator prototype object", +/// https://webidl.spec.whatwg.org/#es-iterator-prototype-object. +/// +/// +/// +/// Its [[Prototype]] is %IteratorPrototype%, so a headers iterator inherits the whole +/// iterator-helper surface (map, take, toArray, …) exactly as a Map iterator does. +/// +/// +/// Each step recomputes sort and combine over the live header list and indexes into the result, which +/// is what https://webidl.spec.whatwg.org/#es-default-iterator-object specifies: a header appended during +/// iteration is seen, and one deleted shifts the rest under the cursor. +/// +/// +[JsObject(UseShape = true)] +internal sealed partial class HeadersIteratorPrototype : IteratorPrototype +{ + [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)] + private static readonly JsString HeadersIteratorToStringTag = new("Headers Iterator"); + + internal HeadersIteratorPrototype( + Engine engine, + Realm realm, + IteratorPrototype iteratorPrototype) : base(engine, realm, iteratorPrototype) + { + } + + protected override void Initialize() + { + CreateProperties_Generated(); + CreateSymbols_Generated(); + } + + [JsFunction(Name = "next")] + private JsValue NextHandler(JsValue thisObject) => Next(thisObject, Arguments.Empty); + + internal IteratorInstance ConstructEntryIterator(JsHeaders headers) + => new HeadersIterator(_engine, headers, IteratorKind.KeyAndValue) { _prototype = this }; + + internal IteratorInstance ConstructKeyIterator(JsHeaders headers) + => new HeadersIterator(_engine, headers, IteratorKind.Key) { _prototype = this }; + + internal IteratorInstance ConstructValueIterator(JsHeaders headers) + => new HeadersIterator(_engine, headers, IteratorKind.Value) { _prototype = this }; + + private enum IteratorKind + { + Key, + Value, + KeyAndValue, + } + + private sealed class HeadersIterator : IteratorInstance + { + private readonly JsHeaders _headers; + private readonly IteratorKind _kind; + private int _index; + + public HeadersIterator(Engine engine, JsHeaders headers, IteratorKind kind) : base(engine) + { + _headers = headers; + _kind = kind; + } + + public override bool TryIteratorStep(out ObjectInstance nextItem) + { + var pairs = _headers.List.SortAndCombine(); + if (_index < pairs.Count) + { + var pair = pairs[_index]; + _index++; + + nextItem = _kind switch + { + IteratorKind.Key => IteratorResult.CreateValueIteratorPosition(_engine, JsString.Create(pair.LowerName)), + IteratorKind.Value => IteratorResult.CreateValueIteratorPosition(_engine, JsString.Create(pair.Value)), + _ => IteratorResult.CreateKeyValueIteratorPosition(_engine, JsString.Create(pair.LowerName), JsString.Create(pair.Value)), + }; + + return true; + } + + nextItem = IteratorResult.CreateKeyValueIteratorPosition(_engine); + return false; + } + } +} +#endif diff --git a/Jint/WebApi/Fetch/HeadersPrototype.cs b/Jint/WebApi/Fetch/HeadersPrototype.cs new file mode 100644 index 0000000000..5a0644e240 --- /dev/null +++ b/Jint/WebApi/Fetch/HeadersPrototype.cs @@ -0,0 +1,229 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Array; +using Jint.Native.Iterator; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; + +namespace Jint.WebApi.Fetch; + +/// +/// Headers.prototype — the interface prototype object. +/// +/// https://fetch.spec.whatwg.org/#headers-class +/// +/// +/// +/// +/// The IDL declares iterable<ByteString, ByteString>, which is what gives the prototype +/// entries, keys, values, forEach and an @@iterator that is the very same +/// function object as entries — https://webidl.spec.whatwg.org/#es-iterable. +/// +/// +/// The pairs iterated over are the result of sort and combine, so iteration is over distinct +/// lowercased names in ascending byte order with their values joined by ", " — whatever order they +/// were appended in — and Set-Cookie is the one name that contributes an entry per value. The list is +/// re-read on every step, as https://webidl.spec.whatwg.org/#es-default-iterator-object specifies, so a +/// callback that mutates the headers is observed. +/// +/// +/// One documented simplification, the same one URL.prototype and Blob.prototype carry: the +/// operations are non-enumerable, where https://webidl.spec.whatwg.org/#es-operations makes an interface's +/// operations enumerable. +/// +/// +[JsSymbolAlias("Iterator", "entries")] +[JsObject(UseShape = true)] +internal sealed partial class HeadersPrototype : Prototype +{ + [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)] + private readonly HeadersConstructor _constructor; + + [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)] + private static readonly JsString HeadersToStringTag = new("Headers"); + + /// + /// The iterator prototype the three iterator-returning members share — per realm, like every other + /// prototype, and here rather than on Intrinsics because nothing else can reach it. + /// + private HeadersIteratorPrototype? _iteratorPrototype; + + internal HeadersPrototype( + Engine engine, + Realm realm, + HeadersConstructor constructor, + ObjectPrototype objectPrototype) : base(engine, realm) + { + _prototype = objectPrototype; + _constructor = constructor; + } + + protected override void Initialize() + { + CreateProperties_Generated(); + CreateSymbols_Generated(); + } + + private HeadersIteratorPrototype IteratorPrototypeObject + => _iteratorPrototype ??= new HeadersIteratorPrototype(_engine, _realm, _realm.Intrinsics.IteratorPrototype); + + /// + /// https://fetch.spec.whatwg.org/#dom-headers-append + /// + [JsFunction(Name = "append", Length = 2)] + private JsValue Append(JsValue thisObject, JsValue name, JsValue value) + { + Brand(thisObject).AppendChecked(_realm, name, value); + return Undefined; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-headers-delete + /// + /// + /// Deleting a name the list does not hold is not an error — the specification's step 5 simply returns. + /// + [JsFunction(Name = "delete", Length = 1)] + private JsValue Delete(JsValue thisObject, JsValue name) + { + var headers = Brand(thisObject); + var headerName = FetchValues.ToByteString(_realm, name); + + if (!HeaderList.IsName(headerName)) + { + Throw.TypeError(_realm, "Failed to execute 'delete' on 'Headers': Invalid name"); + } + + headers.RequireMutable(_realm, "delete"); + headers.List.Delete(headerName); + return Undefined; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-headers-get — the combined value, or null. + /// + [JsFunction(Name = "get", Length = 1)] + private JsValue GetHeader(JsValue thisObject, JsValue name) + { + var headers = Brand(thisObject); + var headerName = FetchValues.ToByteString(_realm, name); + + if (!HeaderList.IsName(headerName)) + { + Throw.TypeError(_realm, "Failed to execute 'get' on 'Headers': Invalid name"); + } + + var value = headers.List.Get(headerName); + return value is null ? Null : JsString.Create(value); + } + + /// + /// https://fetch.spec.whatwg.org/#dom-headers-getsetcookie — the one place a header list's values are + /// handed out uncombined, because a Set-Cookie value may itself contain a comma. + /// + [JsFunction(Name = "getSetCookie", Length = 0)] + private JsArray GetSetCookie(JsValue thisObject) + { + var values = Brand(thisObject).List.GetSetCookie(); + + var items = new JsValue[values.Count]; + for (var i = 0; i < values.Count; i++) + { + items[i] = JsString.Create(values[i]); + } + + return new JsArray(_engine, items); + } + + /// + /// https://fetch.spec.whatwg.org/#dom-headers-has + /// + [JsFunction(Name = "has", Length = 1)] + private JsBoolean Has(JsValue thisObject, JsValue name) + { + var headers = Brand(thisObject); + var headerName = FetchValues.ToByteString(_realm, name); + + if (!HeaderList.IsName(headerName)) + { + Throw.TypeError(_realm, "Failed to execute 'has' on 'Headers': Invalid name"); + } + + return JsBoolean.Create(headers.List.Contains(headerName)); + } + + /// + /// https://fetch.spec.whatwg.org/#dom-headers-set — unlike append this replaces, and the header + /// keeps the position the first one with that name had. + /// + [JsFunction(Name = "set", Length = 2)] + private JsValue SetHeader(JsValue thisObject, JsValue name, JsValue value) + { + var headers = Brand(thisObject); + var (headerName, headerValue) = JsHeaders.Validate(_realm, "set", name, value); + headers.RequireMutable(_realm, "set"); + headers.List.Set(headerName, headerValue); + return Undefined; + } + + /// + /// The default iterator's forEach, https://webidl.spec.whatwg.org/#js-iterable — the callback takes + /// the value first and the name second, and the pairs are recomputed on every step so a callback that + /// mutates the list is observed. + /// + [JsFunction(Name = "forEach", Length = 1)] + private JsValue ForEach(JsValue thisObject, JsValue callback, JsValue thisArg) + { + var headers = Brand(thisObject); + var callable = GetCallable(callback); + + var index = 0; + while (true) + { + var pairs = headers.List.SortAndCombine(); + if (index >= pairs.Count) + { + return Undefined; + } + + var pair = pairs[index]; + index++; + callable.Call(thisArg, [JsString.Create(pair.Value), JsString.Create(pair.LowerName), headers]); + } + } + + /// + /// https://webidl.spec.whatwg.org/#js-iterable — entries, and through the alias above also + /// @@iterator. + /// + [JsFunction(Name = "entries", Length = 0)] + private IteratorInstance Entries(JsValue thisObject) => IteratorPrototypeObject.ConstructEntryIterator(Brand(thisObject)); + + /// + /// https://webidl.spec.whatwg.org/#js-iterable + /// + [JsFunction(Name = "keys", Length = 0)] + private IteratorInstance Keys(JsValue thisObject) => IteratorPrototypeObject.ConstructKeyIterator(Brand(thisObject)); + + /// + /// https://webidl.spec.whatwg.org/#js-iterable + /// + [JsFunction(Name = "values", Length = 0)] + private IteratorInstance Values(JsValue thisObject) => IteratorPrototypeObject.ConstructValueIterator(Brand(thisObject)); + + /// + /// The WebIDL brand check every member performs. + /// + private JsHeaders Brand(JsValue thisObject) + { + if (thisObject is JsHeaders headers) + { + return headers; + } + + Throw.TypeError(_realm, "Illegal invocation: receiver is not a Headers"); + return null!; + } +} +#endif diff --git a/Jint/WebApi/Fetch/JsHeaders.cs b/Jint/WebApi/Fetch/JsHeaders.cs new file mode 100644 index 0000000000..12ba4f339d --- /dev/null +++ b/Jint/WebApi/Fetch/JsHeaders.cs @@ -0,0 +1,165 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Iterator; +using Jint.Native.Object; +using Jint.Native.Symbol; +using Jint.Runtime; + +namespace Jint.WebApi.Fetch; + +/// +/// A Headers instance: the JavaScript face of a . +/// +/// https://fetch.spec.whatwg.org/#headers-class +/// +/// +/// +/// The whole state is the header list and its guard, both of which live on ; the instance +/// itself carries no own property, which is what a browser reports for +/// Object.getOwnPropertyNames(new Headers()). reaches the list through +/// a brand check. +/// +internal sealed class JsHeaders : ObjectInstance +{ + internal JsHeaders(Engine engine, HeaderList list) : base(engine, ObjectClass.Object) + { + List = list; + } + + /// https://fetch.spec.whatwg.org/#concept-headers-header-list + internal HeaderList List { get; } + + /// + /// The header list fill, https://fetch.spec.whatwg.org/#concept-headers-fill — the HeadersInit + /// union's two arms. + /// + /// + /// The union is resolved the way https://webidl.spec.whatwg.org/#es-union says: an object with a callable + /// @@iterator is the sequence arm, anything else the record arm. So a Map and a + /// Headers both fill pairwise, while a plain object fills from its own enumerable string-keyed + /// properties. + /// + internal void Fill(Realm realm, JsValue init) + { + if (init is not ObjectInstance initObject) + { + Throw.TypeError(realm, "Failed to construct 'Headers': The provided value is not of type '(sequence> or record)'"); + return; + } + + var iteratorMethod = initObject.Get(GlobalSymbolRegistry.Iterator); + if (iteratorMethod is ICallable) + { + FillFromSequence(realm, initObject); + return; + } + + FillFromRecord(realm, initObject); + } + + /// + /// "If init is a sequence, then for each header of init: if header's size is not 2, then throw a + /// TypeError; append (header[0], header[1])." + /// + private void FillFromSequence(Realm realm, ObjectInstance init) + { + var outer = init.GetIterator(realm); + while (outer.TryIteratorStepValue(out var header)) + { + var pair = ReadPair(realm, outer, header); + AppendChecked(realm, pair.Name, pair.Value); + } + } + + private static (JsValue Name, JsValue Value) ReadPair(Realm realm, IteratorInstance outer, JsValue header) + { + if (header is not ObjectInstance) + { + outer.Close(CompletionType.Throw); + Throw.TypeError(realm, "Failed to construct 'Headers': The provided value cannot be converted to a sequence"); + } + + var inner = header.GetIterator(realm); + if (!inner.TryIteratorStepValue(out var name) || !inner.TryIteratorStepValue(out var value)) + { + outer.Close(CompletionType.Throw); + Throw.TypeError(realm, "Failed to construct 'Headers': Invalid value — each header must be a name/value pair"); + return default; + } + + // A third element makes the sequence's size not 2, which the fill step refuses outright. + if (inner.TryIteratorStepValue(out _)) + { + inner.Close(CompletionType.Throw); + outer.Close(CompletionType.Throw); + Throw.TypeError(realm, "Failed to construct 'Headers': Invalid value — each header must be a name/value pair"); + } + + return (name, value); + } + + /// + /// "Otherwise, init is a record: for each key → value of init, append (key, value)." A record's members + /// are its own enumerable string-keyed properties, in own-property order — + /// https://webidl.spec.whatwg.org/#es-record. + /// + private void FillFromRecord(Realm realm, ObjectInstance init) + { + foreach (var key in init.GetOwnPropertyKeys(Types.String)) + { + var descriptor = init.GetOwnProperty(key); + if (!descriptor.Enumerable) + { + continue; + } + + AppendChecked(realm, key, init.Get(key)); + } + } + + /// + /// The append operation, https://fetch.spec.whatwg.org/#dom-headers-append, shared by the fill + /// above and by Headers.prototype.append. + /// + internal void AppendChecked(Realm realm, JsValue name, JsValue value) + { + var (headerName, headerValue) = Validate(realm, "append", name, value); + RequireMutable(realm, "append"); + List.Append(headerName, headerValue); + } + + /// + /// Steps 1 and 2 of every mutating operation: normalize the value, then refuse a name that is not a token + /// or a value carrying NUL, CR or LF. + /// + internal static (string Name, string Value) Validate(Realm realm, string operation, JsValue name, JsValue value) + { + var headerName = FetchValues.ToByteString(realm, name); + var headerValue = HeaderList.Normalize(FetchValues.ToByteString(realm, value)); + + if (!HeaderList.IsName(headerName)) + { + Throw.TypeError(realm, $"Failed to execute '{operation}' on 'Headers': Invalid name"); + } + + if (!HeaderList.IsValue(headerValue)) + { + Throw.TypeError(realm, $"Failed to execute '{operation}' on 'Headers': Invalid value"); + } + + return (headerName, headerValue); + } + + /// + /// The guard check every mutating operation performs, + /// https://fetch.spec.whatwg.org/#concept-headers-append step 3. + /// + internal void RequireMutable(Realm realm, string operation) + { + if (List.Guard == HeadersGuard.Immutable) + { + Throw.TypeError(realm, $"Failed to execute '{operation}' on 'Headers': Headers are immutable"); + } + } +} +#endif diff --git a/Jint/WebApi/Fetch/JsRequest.cs b/Jint/WebApi/Fetch/JsRequest.cs new file mode 100644 index 0000000000..702bfa171e --- /dev/null +++ b/Jint/WebApi/Fetch/JsRequest.cs @@ -0,0 +1,63 @@ +#if NET8_0_OR_GREATER +using Jint.WebApi.Abort; +using Jint.WebApi.Url.Parsing; + +namespace Jint.WebApi.Fetch; + +/// +/// A Request instance. +/// +/// https://fetch.spec.whatwg.org/#request-class +/// +/// +/// +/// +/// Every attribute is an accessor on reading this state through a brand check, +/// so an instance has no own property — which is what a browser reports for +/// Object.getOwnPropertyNames(new Request("https://example.org/")). +/// +/// +/// This is the minimal request of the plan's surface v1: the members fetch actually acts on. +/// destination, referrer, referrerPolicy, mode, credentials, cache, +/// integrity, keepalive, isReloadNavigation, isHistoryNavigation and +/// duplex are deliberately absent rather than present and lying: every one of them describes a browser +/// concept — an origin, a cookie jar, an HTTP cache, a navigation — that this engine does not have, and an +/// absent member is what feature detection is written against. +/// +/// +internal sealed class JsRequest : FetchBodyObject +{ + internal JsRequest(Engine engine, JsHeaders headers) : base(engine, headers) + { + } + + /// + /// https://fetch.spec.whatwg.org/#concept-request-method — already normalized, so get is + /// GET while patch stays as written. + /// + internal string Method { get; set; } = "GET"; + + /// + /// https://fetch.spec.whatwg.org/#concept-request-url. Kept as a parsed record rather than as text + /// because a redirect hop has to be resolved against it, and because fetch's scheme policy asks + /// questions of it that re-parsing a string would only have to answer again. + /// + internal UrlRecord Url { get; set; } = null!; + + /// + /// https://fetch.spec.whatwg.org/#concept-request-redirect-mode — follow, error or + /// manual. + /// + internal string Redirect { get; set; } = RedirectFollow; + + /// + /// https://fetch.spec.whatwg.org/#dom-request-signal — never null: the constructor always makes one, and + /// it follows whatever signal the initializer named. + /// + internal JsAbortSignal Signal { get; set; } = null!; + + internal const string RedirectFollow = "follow"; + internal const string RedirectError = "error"; + internal const string RedirectManual = "manual"; +} +#endif diff --git a/Jint/WebApi/Fetch/JsResponse.cs b/Jint/WebApi/Fetch/JsResponse.cs new file mode 100644 index 0000000000..95d7a0d6af --- /dev/null +++ b/Jint/WebApi/Fetch/JsResponse.cs @@ -0,0 +1,69 @@ +#if NET8_0_OR_GREATER +namespace Jint.WebApi.Fetch; + +/// +/// A response's type, https://fetch.spec.whatwg.org/#concept-response-type. +/// +/// +/// Three of the standard's six can arise here. cors, opaque and opaqueredirect are what a +/// browser produces when a response crossed an origin boundary the page is not allowed to see through; Jint +/// has no origin and enforces no same-origin policy — a host that wants one writes a +/// Options.FetchOptions.UrlFilter — so no response is ever filtered and none of the three occurs. +/// +internal enum ResponseType +{ + /// A response the script built itself with new Response(...). + Default, + + /// A response that came off the network. + Basic, + + /// A network error, https://fetch.spec.whatwg.org/#concept-network-error. + Error, +} + +/// +/// A Response instance. +/// +/// https://fetch.spec.whatwg.org/#response-class +/// +/// +/// +/// Every attribute is an accessor on reading this state through a brand check, +/// so an instance has no own property — which is what a browser reports for +/// Object.getOwnPropertyNames(new Response()). +/// +internal sealed class JsResponse : FetchBodyObject +{ + internal JsResponse(Engine engine, JsHeaders headers) : base(engine, headers) + { + } + + /// https://fetch.spec.whatwg.org/#concept-response-status + internal int Status { get; set; } = 200; + + /// https://fetch.spec.whatwg.org/#concept-response-status-message + internal string StatusText { get; set; } = string.Empty; + + /// + /// https://fetch.spec.whatwg.org/#concept-response-type — what the type attribute answers. + /// Named Kind because JsValue.Type already means the JavaScript type of the object. + /// + internal ResponseType Kind { get; set; } = ResponseType.Default; + + /// + /// The serialized last URL of https://fetch.spec.whatwg.org/#concept-response-url-list, excluding its + /// fragment — the empty string when the list is empty, which is every response a script built itself. + /// + internal string Url { get; set; } = string.Empty; + + /// + /// https://fetch.spec.whatwg.org/#dom-response-redirected — whether the URL list has more than one entry, + /// i.e. whether a redirect was followed to reach this response. + /// + internal bool Redirected { get; set; } + + /// https://fetch.spec.whatwg.org/#ok-status + internal bool Ok => Status is >= 200 and <= 299; +} +#endif diff --git a/Jint/WebApi/Fetch/RequestConstructor.cs b/Jint/WebApi/Fetch/RequestConstructor.cs new file mode 100644 index 0000000000..ad965d83ba --- /dev/null +++ b/Jint/WebApi/Fetch/RequestConstructor.cs @@ -0,0 +1,238 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Function; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using Jint.WebApi.Abort; +using Jint.WebApi.Url; +using Jint.WebApi.Url.Parsing; + +namespace Jint.WebApi.Fetch; + +/// +/// The Request interface object. +/// +/// https://fetch.spec.whatwg.org/#request-class +/// +/// +/// +/// constructor(RequestInfo input, optional RequestInit init = {}). As a WebIDL interface object its +/// [[Prototype]] is %Function.prototype% and calling it without new raises a +/// TypeError, which already does. +/// +internal sealed class RequestConstructor : Constructor +{ + private static readonly JsString _functionName = new("Request"); + + internal RequestConstructor( + Engine engine, + Realm realm, + FunctionPrototype functionPrototype, + ObjectPrototype objectPrototype) + : base(engine, realm, _functionName) + { + _prototype = functionPrototype; + PrototypeObject = new RequestPrototype(engine, realm, this, objectPrototype); + _length = new PropertyDescriptor(JsNumber.PositiveOne, PropertyFlag.Configurable); + _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); + } + + internal RequestPrototype PrototypeObject { get; } + + /// + /// https://fetch.spec.whatwg.org/#dom-request, reduced to the members this implementation has — + /// see for which those are and why. + /// + /// + /// + /// There is no base URL. The specification parses a string input against "the entry settings + /// object's API base URL", which is a document's URL; an embedded engine has no document, so a relative + /// URL simply does not parse and is a TypeError. Pass an absolute URL, or resolve it yourself with + /// new URL(relative, base).href. + /// + /// + /// The RequestInit members this implementation does not act on — cache, credentials, + /// integrity, keepalive, mode, priority, referrer, + /// referrerPolicy, window — are neither read nor validated, so a getter among them is not + /// invoked and a misspelled enumeration value is not refused. Accepting and ignoring them is the Node and + /// workerd convention: a script written for a browser keeps working, and nothing here pretends to honour + /// a same-origin policy or an HTTP cache that does not exist. + /// + /// + public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) + { + var input = arguments.At(0); + var init = arguments.At(1); + + // Step 5/6: the input is either a Request to copy or a URL string to parse. + var inputRequest = input as JsRequest; + UrlRecord url; + var method = "GET"; + var redirect = JsRequest.RedirectFollow; + HeaderList headerList; + ReadOnlyMemory? inputBody = null; + JsAbortSignal? signal = null; + + if (inputRequest is not null) + { + url = inputRequest.Url; + method = inputRequest.Method; + redirect = inputRequest.Redirect; + headerList = inputRequest.Headers.List.Clone(); + inputBody = inputRequest.Body; + signal = inputRequest.Signal; + } + else + { + var href = UrlValues.ToUsvString(input); + var parsed = UrlParser.Parse(href); + if (parsed is null) + { + Throw.TypeError(_realm, $"Failed to construct 'Request': Failed to parse URL from {href}"); + } + + // Step 5.3. Credentials in the URL are a phishing and credential-leak shape a browser refuses + // outright, and there is no reason for an embedded engine to be more permissive: an Authorization + // header says the same thing without travelling through logs and Referer headers. + if (parsed.IncludesCredentials) + { + Throw.TypeError(_realm, "Failed to construct 'Request': Request cannot be constructed from a URL that includes credentials"); + } + + url = parsed; + headerList = new HeaderList(); + } + + // WebIDL converts a dictionary's members in lexicographical order of their identifiers, so a bag whose + // members are getters observes body, headers, method, redirect, signal in that order. + var initObject = ToInit(init); + var bodyInit = Member(initObject, "body"); + var headersInit = Member(initObject, "headers"); + var methodInit = Member(initObject, "method"); + var redirectInit = Member(initObject, "redirect"); + var signalInit = Member(initObject, "signal"); + + if (methodInit is not null) + { + var requested = FetchValues.ToByteString(_realm, methodInit); + if (!FetchValues.IsMethod(requested) || FetchValues.IsForbiddenMethod(requested)) + { + Throw.TypeError(_realm, $"Failed to construct 'Request': '{requested}' is not a valid HTTP method."); + } + + method = FetchValues.NormalizeMethod(requested); + } + + if (redirectInit is not null) + { + redirect = FetchValues.ToByteString(_realm, redirectInit); + if (redirect is not (JsRequest.RedirectFollow or JsRequest.RedirectError or JsRequest.RedirectManual)) + { + Throw.TypeError(_realm, $"Failed to construct 'Request': The provided value '{redirect}' is not a valid enum value of type RequestRedirect."); + } + } + + if (signalInit is not null && !signalInit.IsNull()) + { + signal = signalInit as JsAbortSignal; + if (signal is null) + { + Throw.TypeError(_realm, "Failed to construct 'Request': The provided value is not of type 'AbortSignal'"); + } + } + + var headers = _realm.Intrinsics.Headers.CreateInstance(headerList); + headers.List.Guard = HeadersGuard.Request; + + var request = OrdinaryCreateFromConstructor( + newTarget, + static intrinsics => intrinsics.Request.PrototypeObject, + static (Engine engine, Realm _, JsHeaders? state) => new JsRequest(engine, state!), + headers); + + request.Method = method; + request.Url = url; + request.Redirect = redirect; + + // Step 35/36: the request's own signal always exists, and follows the one the initializer named. + request.Signal = signal is null + ? new JsAbortSignal(_engine, _realm) { _prototype = _realm.Intrinsics.AbortSignal.PrototypeObject } + : JsAbortSignal.CreateDependent(_engine, _realm, [signal]); + + // Step 33: an explicit headers member replaces the copied list wholesale, rather than adding to it. + if (headersInit is not null) + { + headerList.Entries.Clear(); + headers.Fill(_realm, headersInit); + } + + var hasInitBody = bodyInit is not null && !bodyInit.IsNull(); + + // Step 37: a body — from either source — is refused for the two methods that cannot carry one. + if ((hasInitBody || inputBody is not null) && method is "GET" or "HEAD") + { + Throw.TypeError(_realm, "Failed to construct 'Request': Request with GET/HEAD method cannot have body."); + } + + if (hasInitBody) + { + // Steps 38-40: extract, then append the implied Content-Type unless the header list — which the + // step above has already filled — carries one of its own. + var (bytes, type) = FetchBody.Extract(_realm, bodyInit!); + FetchBody.SetBody(request, bytes, type); + } + else if (inputBody is not null) + { + // Step 43: "create a proxy for inputBody". A buffered body has nothing to tee, so the two share + // the bytes and each carries its own used flag; what survives the reduction is the check that the + // input still had a body to share. + if (inputRequest!.IsUnusable) + { + Throw.TypeError(_realm, "Failed to construct 'Request': Request body is already used"); + } + + request.Body = inputBody; + } + + return request; + } + + /// + /// WebIDL dictionary conversion: and undefined mean "every member + /// defaulted", and anything that is not an object is a TypeError — + /// https://webidl.spec.whatwg.org/#es-dictionary. + /// + private ObjectInstance? ToInit(JsValue init) + { + if (init.IsNullOrUndefined()) + { + return null; + } + + if (init is not ObjectInstance initObject) + { + Throw.TypeError(_realm, "Failed to construct 'Request': The provided value is not of type 'RequestInit'"); + return null; + } + + return initObject; + } + + /// + /// Reads one dictionary member. Every member of RequestInit is optional with no default value, so + /// an explicitly passed undefined means "not present" — { method: undefined } keeps the + /// method the input had. + /// + internal static JsValue? Member(ObjectInstance? dictionary, string name) + { + if (dictionary is null) + { + return null; + } + + var value = dictionary.Get(name); + return value.IsUndefined() ? null : value; + } +} +#endif diff --git a/Jint/WebApi/Fetch/RequestPrototype.cs b/Jint/WebApi/Fetch/RequestPrototype.cs new file mode 100644 index 0000000000..9904933bce --- /dev/null +++ b/Jint/WebApi/Fetch/RequestPrototype.cs @@ -0,0 +1,195 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using Jint.WebApi.Abort; + +namespace Jint.WebApi.Fetch; + +/// +/// Request.prototype — the interface prototype object. +/// +/// https://fetch.spec.whatwg.org/#request-class +/// +/// +/// +/// +/// The attributes are accessors here rather than own properties of the instance, as WebIDL specifies; each +/// brand-checks its receiver and raises a TypeError for anything that is not a Request — +/// including Request.prototype itself, which is not one. +/// +/// +/// The Body mixin's members are declared here too, because a mixin's members are copied onto every +/// interface that includes it — Request.prototype.text and Response.prototype.text are two +/// different function objects, exactly as in a browser. formData() is absent: reading one back needs a +/// multipart/form-data parser Jint does not have yet, and an absent method is what feature detection +/// is written against. +/// +/// +/// One documented simplification, the same one every other web-API prototype here carries: the operations are +/// non-enumerable, where https://webidl.spec.whatwg.org/#es-operations makes an interface's operations +/// enumerable. +/// +/// +[JsObject(UseShape = true)] +internal sealed partial class RequestPrototype : Prototype +{ + [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)] + private readonly RequestConstructor _constructor; + + [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)] + private static readonly JsString RequestToStringTag = new("Request"); + + internal RequestPrototype( + Engine engine, + Realm realm, + RequestConstructor constructor, + ObjectPrototype objectPrototype) : base(engine, realm) + { + _prototype = objectPrototype; + _constructor = constructor; + } + + protected override void Initialize() + { + CreateProperties_Generated(); + CreateSymbols_Generated(); + } + + /// + /// https://fetch.spec.whatwg.org/#dom-request-method + /// + [JsAccessor("method", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString MethodGet(JsValue thisObject) => JsString.Create(Brand(thisObject).Method); + + /// + /// https://fetch.spec.whatwg.org/#dom-request-url + /// + [JsAccessor("url", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString UrlGet(JsValue thisObject) => JsString.Create(Brand(thisObject).Url.Serialize()); + + /// + /// https://fetch.spec.whatwg.org/#dom-request-headers — [SameObject], so the very same + /// Headers object every time. + /// + [JsAccessor("headers", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsHeaders HeadersGet(JsValue thisObject) => Brand(thisObject).Headers; + + /// + /// https://fetch.spec.whatwg.org/#dom-request-redirect + /// + [JsAccessor("redirect", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString RedirectGet(JsValue thisObject) => JsString.Create(Brand(thisObject).Redirect); + + /// + /// https://fetch.spec.whatwg.org/#dom-request-signal — never null, and the handle + /// fetch links its HTTP request against. + /// + [JsAccessor("signal", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsAbortSignal SignalGet(JsValue thisObject) => Brand(thisObject).Signal; + + /// + /// https://fetch.spec.whatwg.org/#dom-body-body — always null in this version. + /// + /// + /// The attribute's type is ReadableStream? and Jint has no streams, so there is nothing truthful to + /// answer with. It is present rather than absent because null is a meaningful answer the standard itself + /// gives — every request and response without a body has it — and because a script that guards on + /// if (response.body) then takes the buffered path rather than crashing. Streams, and with them a + /// real body, are a follow-up. + /// + [JsAccessor("body", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsValue BodyGet(JsValue thisObject) + { + Brand(thisObject); + return Null; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-body-bodyused + /// + [JsAccessor("bodyUsed", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsBoolean BodyUsedGet(JsValue thisObject) => JsBoolean.Create(Brand(thisObject).BodyUsed); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-arraybuffer + /// + [JsFunction(Name = "arrayBuffer", Length = 0)] + private JsValue ArrayBuffer(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.ArrayBuffer); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-bytes + /// + [JsFunction(Name = "bytes", Length = 0)] + private JsValue Bytes(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Bytes); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-blob + /// + [JsFunction(Name = "blob", Length = 0)] + private JsValue Blob(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Blob); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-json + /// + [JsFunction(Name = "json", Length = 0)] + private JsValue Json(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Json); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-text + /// + [JsFunction(Name = "text", Length = 0)] + private JsValue Text(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Text); + + /// + /// https://fetch.spec.whatwg.org/#dom-request-clone — the one member of the pair that throws + /// synchronously rather than rejecting, because it is not defined to return a promise. + /// + [JsFunction(Name = "clone", Length = 0)] + private JsRequest Clone(JsValue thisObject) + { + var request = Brand(thisObject); + if (request.IsUnusable) + { + Throw.TypeError(_realm, "Failed to execute 'clone' on 'Request': Request body is already used"); + } + + var headers = _realm.Intrinsics.Headers.CreateInstance(request.Headers.List.Clone()); + headers.List.Guard = request.Headers.List.Guard; + + return new JsRequest(_engine, headers) + { + _prototype = _realm.Intrinsics.Request.PrototypeObject, + Method = request.Method, + Url = request.Url, + Redirect = request.Redirect, + + // Step 4: "set clonedRequestObject's signal to the result of creating a dependent abort signal + // given « this's signal »" — the clone aborts with the original, never the other way round. + Signal = JsAbortSignal.CreateDependent(_engine, _realm, [request.Signal]), + + // The bytes are shared; only the used flag is the clone's own, which is the whole point of + // cloning a response before reading it twice. + Body = request.Body, + }; + } + + private JsValue Consume(JsValue thisObject, BodyConsumeKind kind) + => FetchBody.Consume(_engine, _realm, Brand(thisObject), kind); + + /// + /// The WebIDL brand check every member performs. + /// + private JsRequest Brand(JsValue thisObject) + { + if (thisObject is JsRequest request) + { + return request; + } + + Throw.TypeError(_realm, "Illegal invocation: receiver is not a Request"); + return null!; + } +} +#endif diff --git a/Jint/WebApi/Fetch/ResponseConstructor.cs b/Jint/WebApi/Fetch/ResponseConstructor.cs new file mode 100644 index 0000000000..c12a426462 --- /dev/null +++ b/Jint/WebApi/Fetch/ResponseConstructor.cs @@ -0,0 +1,241 @@ +#if NET8_0_OR_GREATER +using System.Buffers; +using Jint.Native; +using Jint.Native.Function; +using Jint.Native.Json; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using Jint.WebApi.Url; +using Jint.WebApi.Url.Parsing; + +namespace Jint.WebApi.Fetch; + +/// +/// The Response interface object, and its three statics error, redirect and json. +/// +/// https://fetch.spec.whatwg.org/#response-class +/// +/// +/// +/// constructor(optional BodyInit? body = null, optional ResponseInit init = {}). As a WebIDL interface +/// object its [[Prototype]] is %Function.prototype% and calling it without new raises a +/// TypeError, which already does. +/// +[JsObject(UseShape = true)] +internal sealed partial class ResponseConstructor : Constructor +{ + private static readonly JsString _functionName = new("Response"); + + internal ResponseConstructor( + Engine engine, + Realm realm, + FunctionPrototype functionPrototype, + ObjectPrototype objectPrototype) + : base(engine, realm, _functionName) + { + _prototype = functionPrototype; + PrototypeObject = new ResponsePrototype(engine, realm, this, objectPrototype); + _length = new PropertyDescriptor(JsNumber.PositiveZero, PropertyFlag.Configurable); + _prototypeDescriptor = new PropertyDescriptor(PrototypeObject, PropertyFlag.AllForbidden); + } + + internal ResponsePrototype PrototypeObject { get; } + + protected override void Initialize() => CreateProperties_Generated(); + + /// + /// https://fetch.spec.whatwg.org/#dom-response + /// + public override ObjectInstance Construct(JsCallArguments arguments, JsValue newTarget) + { + var body = arguments.At(0); + var init = arguments.At(1); + + var response = OrdinaryCreateFromConstructor( + newTarget, + static intrinsics => intrinsics.Response.PrototypeObject, + static (Engine engine, Realm _, JsHeaders? state) => new JsResponse(engine, state!), + NewResponseHeaders()); + + // Steps 3 and 4: the body is extracted *before* initialize runs its range checks, so + // `new Response(new FormData(), { status: 999 })` reports the unsupported body rather than the + // status. body is optional and nullable, so an omitted argument and an explicit null both mean none. + (ReadOnlyMemory Bytes, string? Type)? bodyWithType = null; + if (!body.IsNullOrUndefined()) + { + bodyWithType = FetchBody.Extract(_realm, body); + } + + Initialize(response, init, bodyWithType); + return response; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-response-error — a response representing a network error. Its + /// headers are immutable and its status is 0, so ok is false and nothing about it can be edited. + /// + [JsFunction(Name = "error", Length = 0)] + private JsResponse Error(JsValue thisObject) + { + var response = NewResponse(); + response.Status = 0; + response.Kind = ResponseType.Error; + response.Headers.List.Guard = HeadersGuard.Immutable; + return response; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-response-redirect + /// + /// + /// The URL is parsed without a base, for the reason Request's constructor documents: an embedded + /// engine has no document to be relative to. + /// + [JsFunction(Name = "redirect", Length = 1)] + private JsResponse Redirect(JsValue thisObject, JsValue url, JsValue status) + { + var href = UrlValues.ToUsvString(url); + var parsed = UrlParser.Parse(href); + if (parsed is null) + { + Throw.TypeError(_realm, $"Failed to execute 'redirect' on 'Response': Failed to parse URL from {href}"); + } + + // The IDL default is 302, taken by an omitted argument and by an explicit undefined alike. + var code = status.IsUndefined() ? 302 : FetchValues.ToUnsignedShort(status); + if (!FetchValues.IsRedirectStatus(code)) + { + Throw.RangeError(_realm, $"Failed to execute 'redirect' on 'Response': Invalid status code {code}"); + } + + var response = NewResponse(); + response.Status = code; + response.Headers.List.Guard = HeadersGuard.Immutable; + + // Appended past the guard deliberately: the guard is what the *script* may not edit, and step 5 of + // the algorithm appends this after setting it. + response.Headers.List.Append("location", parsed.Serialize()); + return response; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-response-json + /// + [JsFunction(Name = "json", Length = 1)] + private JsResponse Json(JsValue thisObject, JsValue data, JsValue init) + { + // "Serialize a JavaScript value to JSON bytes", https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-json-bytes: + // JSON.stringify, and a TypeError when it answers undefined — which is what `undefined`, a function + // and a symbol all do. + var writer = new ArrayBufferWriter(); + if (!new JsonSerializer(_engine).Serialize(data, writer)) + { + Throw.TypeError(_realm, "Failed to execute 'json' on 'Response': The data is not JSON serializable"); + } + + var response = NewResponse(); + Initialize(response, init, (writer.WrittenMemory, FetchBody.JsonContentType)); + return response; + } + + /// + /// "Initialize a response", https://fetch.spec.whatwg.org/#initialize-a-response — the status, status + /// message, headers and body a ResponseInit carries, shared by the constructor and by + /// Response.json. + /// + private void Initialize(JsResponse response, JsValue init, (ReadOnlyMemory Bytes, string? Type)? body) + { + var initObject = ToInit(init); + + // Lexicographical order of the dictionary's members: headers, status, statusText. The two range + // checks come after every member has been read, which is what the algorithm's own step order says. + var headersInit = RequestConstructor.Member(initObject, "headers"); + var statusInit = RequestConstructor.Member(initObject, "status"); + var statusTextInit = RequestConstructor.Member(initObject, "statusText"); + + var status = statusInit is null ? 200 : FetchValues.ToUnsignedShort(statusInit); + if (status is < 200 or > 599) + { + Throw.RangeError(_realm, $"Failed to construct 'Response': The status provided ({status}) is outside the range [200, 599]."); + } + + var statusText = statusTextInit is null ? string.Empty : FetchValues.ToByteString(_realm, statusTextInit); + if (!FetchValues.IsReasonPhrase(statusText)) + { + Throw.TypeError(_realm, $"Failed to construct 'Response': Invalid status text: '{statusText}'"); + } + + response.Status = status; + response.StatusText = statusText; + + if (headersInit is not null) + { + response.Headers.Fill(_realm, headersInit); + } + + if (body is not { } bodyWithType) + { + return; + } + + // Step 6.1 — and it applies to Response.json too, so `Response.json(null, { status: 204 })` is a + // TypeError rather than a 204 carrying "null". + if (FetchValues.IsNullBodyStatus(response.Status)) + { + Throw.TypeError(_realm, "Failed to construct 'Response': Response with null body status cannot have body"); + } + + FetchBody.SetBody(response, bodyWithType.Bytes, bodyWithType.Type); + } + + private ObjectInstance? ToInit(JsValue init) + { + if (init.IsNullOrUndefined()) + { + return null; + } + + if (init is not ObjectInstance initObject) + { + Throw.TypeError(_realm, "Failed to construct 'Response': The provided value is not of type 'ResponseInit'"); + return null; + } + + return initObject; + } + + /// + /// A fresh response object, for the three statics — none of which goes through + /// OrdinaryCreateFromConstructor, because none of them takes a newTarget. + /// + private JsResponse NewResponse() + => new(_engine, NewResponseHeaders()) { _prototype = PrototypeObject }; + + /// + /// An empty header list under the response guard, which every response starts with. + /// + private JsHeaders NewResponseHeaders() + { + var headers = _realm.Intrinsics.Headers.CreateInstance(new HeaderList()); + headers.List.Guard = HeadersGuard.Response; + return headers; + } + + /// + /// Builds a Response for a response the engine produced itself, which is what fetch settles + /// its promise with. + /// + internal JsResponse CreateInstance(HeaderList headerList) + { + var headers = _realm.Intrinsics.Headers.CreateInstance(headerList); + headers.List.Guard = HeadersGuard.Response; + + return new JsResponse(_engine, headers) + { + _prototype = PrototypeObject, + Kind = ResponseType.Basic, + }; + } +} +#endif diff --git a/Jint/WebApi/Fetch/ResponsePrototype.cs b/Jint/WebApi/Fetch/ResponsePrototype.cs new file mode 100644 index 0000000000..41898d9e2e --- /dev/null +++ b/Jint/WebApi/Fetch/ResponsePrototype.cs @@ -0,0 +1,202 @@ +#if NET8_0_OR_GREATER +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime; +using Jint.Runtime.Descriptors; + +namespace Jint.WebApi.Fetch; + +/// +/// Response.prototype — the interface prototype object. +/// +/// https://fetch.spec.whatwg.org/#response-class +/// +/// +/// +/// +/// The attributes are accessors here rather than own properties of the instance, as WebIDL specifies; each +/// brand-checks its receiver and raises a TypeError for anything that is not a Response — +/// including Response.prototype itself, which is not one. +/// +/// +/// The Body mixin's members are declared here as well as on , because a +/// mixin's members are copied onto every interface that includes it: the two text functions are +/// different objects, exactly as in a browser. formData() is absent — see +/// . +/// +/// +[JsObject(UseShape = true)] +internal sealed partial class ResponsePrototype : Prototype +{ + private static readonly JsString _typeDefault = new("default"); + private static readonly JsString _typeBasic = new("basic"); + private static readonly JsString _typeError = new("error"); + + [JsProperty(Name = "constructor", Flags = PropertyFlag.NonEnumerable)] + private readonly ResponseConstructor _constructor; + + [JsSymbol("ToStringTag", Flags = PropertyFlag.Configurable)] + private static readonly JsString ResponseToStringTag = new("Response"); + + internal ResponsePrototype( + Engine engine, + Realm realm, + ResponseConstructor constructor, + ObjectPrototype objectPrototype) : base(engine, realm) + { + _prototype = objectPrototype; + _constructor = constructor; + } + + protected override void Initialize() + { + CreateProperties_Generated(); + CreateSymbols_Generated(); + } + + /// + /// https://fetch.spec.whatwg.org/#dom-response-type + /// + [JsAccessor("type", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString TypeGet(JsValue thisObject) => Brand(thisObject).Kind switch + { + ResponseType.Basic => _typeBasic, + ResponseType.Error => _typeError, + _ => _typeDefault, + }; + + /// + /// https://fetch.spec.whatwg.org/#dom-response-url — the empty string for a response a script built + /// itself, which has no URL list at all. + /// + [JsAccessor("url", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString UrlGet(JsValue thisObject) => JsString.Create(Brand(thisObject).Url); + + /// + /// https://fetch.spec.whatwg.org/#dom-response-redirected + /// + [JsAccessor("redirected", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsBoolean RedirectedGet(JsValue thisObject) => JsBoolean.Create(Brand(thisObject).Redirected); + + /// + /// https://fetch.spec.whatwg.org/#dom-response-status + /// + [JsAccessor("status", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsNumber StatusGet(JsValue thisObject) => JsNumber.Create(Brand(thisObject).Status); + + /// + /// https://fetch.spec.whatwg.org/#dom-response-ok + /// + [JsAccessor("ok", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsBoolean OkGet(JsValue thisObject) => JsBoolean.Create(Brand(thisObject).Ok); + + /// + /// https://fetch.spec.whatwg.org/#dom-response-statustext + /// + [JsAccessor("statusText", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsString StatusTextGet(JsValue thisObject) => JsString.Create(Brand(thisObject).StatusText); + + /// + /// https://fetch.spec.whatwg.org/#dom-response-headers — [SameObject], so the very same + /// Headers object every time. + /// + [JsAccessor("headers", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsHeaders HeadersGet(JsValue thisObject) => Brand(thisObject).Headers; + + /// + /// https://fetch.spec.whatwg.org/#dom-body-body — always null in this version; see + /// for why it is present rather than absent. + /// + [JsAccessor("body", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsValue BodyGet(JsValue thisObject) + { + Brand(thisObject); + return Null; + } + + /// + /// https://fetch.spec.whatwg.org/#dom-body-bodyused + /// + [JsAccessor("bodyUsed", Flags = PropertyFlag.Configurable | PropertyFlag.Enumerable)] + private JsBoolean BodyUsedGet(JsValue thisObject) => JsBoolean.Create(Brand(thisObject).BodyUsed); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-arraybuffer + /// + [JsFunction(Name = "arrayBuffer", Length = 0)] + private JsValue ArrayBuffer(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.ArrayBuffer); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-bytes + /// + [JsFunction(Name = "bytes", Length = 0)] + private JsValue Bytes(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Bytes); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-blob + /// + [JsFunction(Name = "blob", Length = 0)] + private JsValue Blob(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Blob); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-json + /// + [JsFunction(Name = "json", Length = 0)] + private JsValue Json(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Json); + + /// + /// https://fetch.spec.whatwg.org/#dom-body-text + /// + [JsFunction(Name = "text", Length = 0)] + private JsValue Text(JsValue thisObject) => Consume(thisObject, BodyConsumeKind.Text); + + /// + /// https://fetch.spec.whatwg.org/#dom-response-clone — a synchronous TypeError for an already-read + /// body, because clone does not return a promise to reject. + /// + /// + /// The clone shares the original's bytes and carries its own used flag, which is what makes the + /// const copy = response.clone(); await response.json(); await copy.text(); pattern work. + /// + [JsFunction(Name = "clone", Length = 0)] + private JsResponse Clone(JsValue thisObject) + { + var response = Brand(thisObject); + if (response.IsUnusable) + { + Throw.TypeError(_realm, "Failed to execute 'clone' on 'Response': Response body is already used"); + } + + var headers = _realm.Intrinsics.Headers.CreateInstance(response.Headers.List.Clone()); + headers.List.Guard = response.Headers.List.Guard; + + return new JsResponse(_engine, headers) + { + _prototype = _realm.Intrinsics.Response.PrototypeObject, + Status = response.Status, + StatusText = response.StatusText, + Kind = response.Kind, + Url = response.Url, + Redirected = response.Redirected, + Body = response.Body, + }; + } + + private JsValue Consume(JsValue thisObject, BodyConsumeKind kind) + => FetchBody.Consume(_engine, _realm, Brand(thisObject), kind); + + /// + /// The WebIDL brand check every member performs. + /// + private JsResponse Brand(JsValue thisObject) + { + if (thisObject is JsResponse response) + { + return response; + } + + Throw.TypeError(_realm, "Illegal invocation: receiver is not a Response"); + return null!; + } +} +#endif diff --git a/Jint/WebApi/Files/FileApi.cs b/Jint/WebApi/Files/FileApi.cs index 24498f7136..8262799700 100644 --- a/Jint/WebApi/Files/FileApi.cs +++ b/Jint/WebApi/Files/FileApi.cs @@ -69,29 +69,57 @@ internal static byte[] ProcessBlobParts(Realm realm, JsValue parts) private static void AppendBlobPart(ArrayBufferWriter writer, JsValue element) { - switch (element) + if (TryGetBufferSourceBytes(element, out var bytes)) + { + Append(writer, bytes); + return; + } + + if (element is JsBlob blob) + { + Append(writer, blob.Data.Span); + return; + } + + AppendUtf8(writer, TypeConverter.ToString(element)); + } + + /// + /// The WebIDL BufferSource typedef, https://webidl.spec.whatwg.org/#BufferSource — an + /// ArrayBuffer or any view over one — and the bytes it currently holds. + /// + /// + /// + /// The span is a window onto the buffer script still owns, so a caller that keeps the bytes must copy + /// them. A detached buffer holds none, so it answers with an empty span: nothing + /// here can observe the difference between that and an empty buffer. + /// + /// + /// A SharedArrayBuffer is deliberately not one. The typedef covers ArrayBuffer and the + /// views, and sharing is only admitted where [AllowShared] says so, which neither BlobPart + /// nor XMLHttpRequestBodyInit does — so it falls through to the string arm of whichever union is + /// being converted and is stringified, exactly as in a browser. + /// + /// + internal static bool TryGetBufferSourceBytes(JsValue value, out ReadOnlySpan bytes) + { + switch (value) { - // A detached buffer holds no bytes to copy, so it contributes none. Nothing here can observe - // the difference between that and an empty buffer. case JsArrayBuffer { IsSharedArrayBuffer: false } buffer: - Append(writer, buffer.ArrayBufferData.AsSpan()); - return; + bytes = buffer.ArrayBufferData.AsSpan(); + return true; case JsTypedArray typedArray: - Append(writer, ViewBytes(typedArray)); - return; + bytes = ViewBytes(typedArray); + return true; case JsDataView dataView: - Append(writer, ViewBytes(dataView)); - return; - - case JsBlob blob: - Append(writer, blob.Data.Span); - return; + bytes = ViewBytes(dataView); + return true; default: - AppendUtf8(writer, TypeConverter.ToString(element)); - return; + bytes = default; + return false; } } diff --git a/Jint/WebApi/WebApiOptionsExtensions.cs b/Jint/WebApi/WebApiOptionsExtensions.cs index 73263cb856..af59a3a37e 100644 --- a/Jint/WebApi/WebApiOptionsExtensions.cs +++ b/Jint/WebApi/WebApiOptionsExtensions.cs @@ -89,6 +89,49 @@ public static Options UseWebApis(this Options options, Action + /// Enables fetch, Headers, Request and Response — and with them + /// , and + /// , which are part of fetch's own surface. + /// + /// + /// + /// This is the only call that grants a script outbound network access, and + /// deliberately never does. What the script can then reach is whatever + /// the host process can reach: read — in particular + /// — before enabling this in anything that runs untrusted + /// script. + /// + /// + /// Only the flag is recorded; the three it implies are added when the + /// engine is built, so options.WebApi.Features still reads back exactly what was asked for. + /// + /// + /// + /// + /// var engine = new Engine(options => options.UseWebApis().UseFetch(f => + /// { + /// f.AllowedSchemes.Remove("http"); + /// f.UrlFilter = uri => uri.Host.EndsWith(".example.org", StringComparison.OrdinalIgnoreCase); + /// f.MaxResponseBytes = 1024 * 1024; + /// })); + /// + /// + /// Options to modify. + /// Optional configuration of the fetch settings, run after the feature is enabled. + /// Options instance for fluent syntax. + public static Options UseFetch(this Options options, Action? configure = null) + { + if (options is null) + { + Throw.ArgumentNullException(nameof(options)); + } + + options.WebApi.Features |= WebApiFeatures.Fetch; + configure?.Invoke(options.WebApi.Fetch); + return options; + } + /// /// Enables the console object and sends its output to . /// diff --git a/Jint/WebApi/WebApiRegistration.cs b/Jint/WebApi/WebApiRegistration.cs index f5f06e308f..11abc66b35 100644 --- a/Jint/WebApi/WebApiRegistration.cs +++ b/Jint/WebApi/WebApiRegistration.cs @@ -32,7 +32,7 @@ internal static class WebApiRegistration internal static void Apply(Options options, Engine engine) { var global = engine.Realm.GlobalObject; - var features = options.WebApi.Features; + var features = ExpandFeatures(options.WebApi.Features); CreateEngineState(options, engine, features); @@ -113,6 +113,44 @@ internal static void Apply(Options options, Engine engine) { Install(global, engine, "performance", static e => e.Realm.Intrinsics.Performance, PropertyFlag.ConfigurableEnumerableWritable); } + + if ((features & WebApiFeatures.Fetch) != WebApiFeatures.None) + { + Install(global, engine, "Headers", static e => e.Realm.Intrinsics.Headers, PropertyFlag.NonEnumerable); + Install(global, engine, "Request", static e => e.Realm.Intrinsics.Request, PropertyFlag.NonEnumerable); + Install(global, engine, "Response", static e => e.Realm.Intrinsics.Response, PropertyFlag.NonEnumerable); + } + } + + /// + /// The feature closure: a feature whose own surface is built out of another feature's interfaces brings + /// that one with it. + /// + /// + /// + /// Computed here rather than in UseFetch because this is the only place every door leads through: + /// a host may also assign options.WebApi.Features outright, and a closure applied in one extension + /// method would leave that host with a fetch whose Request has no AbortSignal to + /// build. The consequence is deliberate and worth stating: options.WebApi.Features keeps reading + /// back exactly what the host asked for — enabling fetch does not silently rewrite it to name four + /// features — while the engine carries the closure. + /// + /// + /// Fetch is the only feature with implications today. Request always has an AbortSignal + /// (), its URL is a WHATWG URL record + /// () and response.blob() answers with a Blob + /// (); none of the three is optional to the implementation, so + /// installing fetch without them would ship an interface that throws on its own members. + /// + /// + private static WebApiFeatures ExpandFeatures(WebApiFeatures features) + { + if ((features & WebApiFeatures.Fetch) != WebApiFeatures.None) + { + features |= WebApiFeatures.Events | WebApiFeatures.Url | WebApiFeatures.Files; + } + + return features; } ///