diff --git a/__tests__/polyfills/responseBody.test.ts b/__tests__/polyfills/responseBody.test.ts new file mode 100644 index 000000000..10cdeb5f8 --- /dev/null +++ b/__tests__/polyfills/responseBody.test.ts @@ -0,0 +1,132 @@ +/** + * Response.body ReadableStream Polyfill Tests + * + * React Native's fetch (whatwg-fetch) implements Body.text()/arrayBuffer()/json() + * but does NOT expose the streaming `Response.body` getter. Stellar SDK v16's + * feaxios bounded-fetch adapter (used by StellarToml.Resolver and + * Federation.Server whenever maxContentLength/maxRedirects are set) reads the + * response exclusively via `response.body.getReader()`. With `body` undefined the + * adapter fell back to an empty buffer, so every stellar.toml parsed to `{}` and + * federation lookups failed with "stellar.toml does not contain FEDERATION_SERVER + * field". + * + * Jest (V8/Node) provides a native streaming `Response.body`, so these tests run + * the polyfill against plain prototype objects lacking `body` to reproduce the + * React Native gap. + */ +type ArrayBufferBody = { arrayBuffer(): Promise }; + +type ReadableLike = { + getReader(): { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + }; +}; + +let createResponseBodyStream: (response: ArrayBufferBody) => ReadableLike; +let installResponseBodyPolyfill: (target?: unknown) => void; + +beforeAll(() => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + ({ + createResponseBodyStream, + installResponseBodyPolyfill, + } = require("../../src/polyfills/responseBody")); +}); + +const encode = (text: string): ArrayBuffer => { + const { buffer, byteOffset, byteLength } = new TextEncoder().encode(text); + return buffer.slice(byteOffset, byteOffset + byteLength); +}; + +const readStream = async (stream: ReadableLike): Promise => { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + for (;;) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) break; + if (value) { + chunks.push(value); + total += value.byteLength; + } + } + + const merged = new Uint8Array(total); + let offset = 0; + chunks.forEach((chunk) => { + merged.set(chunk, offset); + offset += chunk.byteLength; + }); + + return new TextDecoder().decode(merged); +}; + +describe("Response.body polyfill", () => { + it("createResponseBodyStream streams the body that arrayBuffer() returns", async () => { + // A real stellar.toml snippet: the streamed bytes must round-trip intact so + // the SDK's smol-toml parse can recover FEDERATION_SERVER, the field whose + // absence broke federation resolution. + const toml = 'FEDERATION_SERVER = "https://stellar.org/federation"\n'; + const response = { arrayBuffer: () => Promise.resolve(encode(toml)) }; + + const text = await readStream(createResponseBodyStream(response)); + + expect(text).toBe(toml); + }); + + it("createResponseBodyStream surfaces arrayBuffer() rejection as a stream error", async () => { + const failure = new Error("network down"); + const response = { arrayBuffer: () => Promise.reject(failure) }; + + await expect( + readStream(createResponseBodyStream(response)), + ).rejects.toThrow("network down"); + }); + + it("installs a working Response.body getter when one is missing", async () => { + const toml = 'FEDERATION_SERVER = "https://example.com/federation"\n'; + const prototype: ArrayBufferBody = { + arrayBuffer: () => Promise.resolve(encode(toml)), + }; + const target = { prototype } as unknown as typeof Response; + + expect(Object.getOwnPropertyDescriptor(prototype, "body")).toBeUndefined(); + + installResponseBodyPolyfill(target); + + const response = Object.create(prototype) as { body: ReadableLike | null }; + expect(response.body).not.toBeNull(); + expect(await readStream(response.body as ReadableLike)).toBe(toml); + }); + + it("memoizes the stream so the single-use body is not consumed twice", () => { + const prototype: ArrayBufferBody = { + arrayBuffer: () => Promise.resolve(encode('FEDERATION_SERVER = "x"')), + }; + const target = { prototype } as unknown as typeof Response; + + installResponseBodyPolyfill(target); + + const response = Object.create(prototype) as { body: ReadableLike }; + expect(response.body).toBe(response.body); + }); + + it("does not override a runtime that already provides Response.body", () => { + const nativeStream = Symbol("native-body"); + const prototype: ArrayBufferBody = { + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + }; + Object.defineProperty(prototype, "body", { + configurable: true, + get: () => nativeStream, + }); + const target = { prototype } as unknown as typeof Response; + + installResponseBodyPolyfill(target); + + const response = Object.create(prototype) as { body: unknown }; + expect(response.body).toBe(nativeStream); + }); +}); diff --git a/android/app/build.gradle b/android/app/build.gradle index d34731c4c..90c492b3c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -141,7 +141,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1234567890 - versionName "1.19.26" + versionName "1.19.27" } buildTypes { diff --git a/ios/freighter-mobile.xcodeproj/project.pbxproj b/ios/freighter-mobile.xcodeproj/project.pbxproj index c3cf4b2c5..0526e587c 100644 --- a/ios/freighter-mobile.xcodeproj/project.pbxproj +++ b/ios/freighter-mobile.xcodeproj/project.pbxproj @@ -505,7 +505,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.19.26; + MARKETING_VERSION = 1.19.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -542,7 +542,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.19.26; + MARKETING_VERSION = 1.19.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -740,7 +740,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.19.26; + MARKETING_VERSION = 1.19.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -775,7 +775,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.19.26; + MARKETING_VERSION = 1.19.27; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/freighter-mobile/Info-Dev.plist b/ios/freighter-mobile/Info-Dev.plist index ead6b9c24..0c9aff850 100644 --- a/ios/freighter-mobile/Info-Dev.plist +++ b/ios/freighter-mobile/Info-Dev.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.19.26 + 1.19.27 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/freighter-mobile/Info.plist b/ios/freighter-mobile/Info.plist index 4507e3130..14489e219 100644 --- a/ios/freighter-mobile/Info.plist +++ b/ios/freighter-mobile/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.19.26 + 1.19.27 CFBundleSignature ???? CFBundleURLTypes diff --git a/package.json b/package.json index 174a20c11..7549cd91a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "freighter-mobile", - "version": "1.19.26", + "version": "1.19.27", "license": "Apache-2.0", "scripts": { "android": "yarn android-dev", @@ -147,6 +147,7 @@ "url": "0.11.4", "util": "0.10.3", "vm-browserify": "0.0.4", + "web-streams-polyfill": "4.0.0-beta.3", "zeego": "3.0.6", "zustand": "5.0.6" }, diff --git a/src/bootstrap.js b/src/bootstrap.js index bc40e2554..cf6b86644 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.js @@ -14,5 +14,10 @@ require("./polyfills/xhr"); // Stellar SDK's feaxios HTTP client needs for transaction submission. require("./polyfills/abortSignal"); +// Response.body polyfill - React Native's fetch lacks the streaming body getter, +// which the Stellar SDK's bounded-fetch adapter (stellar.toml/federation +// resolution) reads from. Without it federation addresses fail to resolve. +require("./polyfills/responseBody"); + // Export nothing - this file is used only for side effects module.exports = {}; diff --git a/src/polyfills/responseBody.ts b/src/polyfills/responseBody.ts new file mode 100644 index 000000000..be8d31e5f --- /dev/null +++ b/src/polyfills/responseBody.ts @@ -0,0 +1,86 @@ +/** + * Response.body ReadableStream Polyfill for React Native + * + * React Native's fetch (whatwg-fetch) implements the Body consumers + * `text()`/`arrayBuffer()`/`json()` but never exposes the streaming + * `Response.body` getter, so `response.body` is `undefined`. + * + * The Stellar SDK (v16+) routes HTTP through `feaxios`. Whenever a request sets + * `maxContentLength` or `maxRedirects` it takes the SDK's bounded-fetch adapter, + * which reads the response body *exclusively* via `response.body.getReader()`. + * `StellarToml.Resolver.resolve()` and `Federation.Server` both set those + * options, so on React Native the adapter saw `body === undefined`, fell back to + * an empty buffer, and every stellar.toml parsed to `{}`. Federation lookups then + * failed with "stellar.toml does not contain FEDERATION_SERVER field". + * + * This polyfill backs the missing getter with the `arrayBuffer()` that React + * Native does support, exposing a single-chunk `ReadableStream`. It is guarded so + * it no-ops on runtimes (Node/V8 in Jest, future RN) that already provide + * `Response.body`. + * + * `ReadableStream` is imported from `web-streams-polyfill` because Hermes does not + * provide a global one. + */ +import { ReadableStream } from "web-streams-polyfill"; + +/** The subset of `Response` this polyfill relies on. */ +interface ArrayBufferBody { + arrayBuffer(): Promise; +} + +/** + * Builds a one-shot `ReadableStream` that emits the response body as a single + * `Uint8Array` chunk, sourced from `arrayBuffer()`. A rejected `arrayBuffer()` + * surfaces as a stream error so callers see the original failure. + */ +export const createResponseBodyStream = ( + response: ArrayBufferBody, +): ReadableStream => { + const bodyPromise = response.arrayBuffer(); + + return new ReadableStream({ + start(controller) { + bodyPromise + .then((buffer) => { + controller.enqueue(new Uint8Array(buffer)); + controller.close(); + }) + .catch((error: unknown) => controller.error(error)); + }, + }); +}; + +/** + * Defines a lazy `body` getter on the target's prototype when it is missing. The + * stream is memoized per response instance so repeated access returns the same + * stream and the single-use body is not consumed twice. + */ +export const installResponseBodyPolyfill = ( + ResponseCtor: typeof Response | undefined = typeof Response === "undefined" + ? undefined + : Response, +): void => { + if (!ResponseCtor) { + return; + } + + if (Object.getOwnPropertyDescriptor(ResponseCtor.prototype, "body")) { + return; + } + + Object.defineProperty(ResponseCtor.prototype, "body", { + configurable: true, + get(this: ArrayBufferBody): ReadableStream { + const stream = createResponseBodyStream(this); + Object.defineProperty(this, "body", { + configurable: true, + value: stream, + }); + return stream; + }, + }); +}; + +installResponseBodyPolyfill(); + +export {}; diff --git a/yarn.lock b/yarn.lock index 82ee862f3..076c96f0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10031,6 +10031,7 @@ __metadata: url: "npm:0.11.4" util: "npm:0.10.3" vm-browserify: "npm:0.0.4" + web-streams-polyfill: "npm:4.0.0-beta.3" zeego: "npm:3.0.6" zustand: "npm:5.0.6" languageName: unknown