Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions __tests__/polyfills/responseBody.test.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> };

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<string> => {
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);
});
});
2 changes: 1 addition & 1 deletion android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1234567890
versionName "1.19.26"
versionName "1.19.27"
}

buildTypes {
Expand Down
8 changes: 4 additions & 4 deletions ios/freighter-mobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.19.26;
MARKETING_VERSION = 1.19.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down Expand Up @@ -542,7 +542,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.19.26;
MARKETING_VERSION = 1.19.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down Expand Up @@ -740,7 +740,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.19.26;
MARKETING_VERSION = 1.19.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down Expand Up @@ -775,7 +775,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.19.26;
MARKETING_VERSION = 1.19.27;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
Expand Down
2 changes: 1 addition & 1 deletion ios/freighter-mobile/Info-Dev.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.19.26</string>
<string>1.19.27</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
Expand Down
2 changes: 1 addition & 1 deletion ios/freighter-mobile/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.19.26</string>
<string>1.19.27</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freighter-mobile",
"version": "1.19.26",
"version": "1.19.27",
"license": "Apache-2.0",
"scripts": {
"android": "yarn android-dev",
Expand Down Expand Up @@ -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"
},
Expand Down
5 changes: 5 additions & 0 deletions src/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {};
86 changes: 86 additions & 0 deletions src/polyfills/responseBody.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>;
}

/**
* 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<Uint8Array> => {
const bodyPromise = response.arrayBuffer();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer reading until the stream is consumed

When callers only probe or cancel response.body, this starts arrayBuffer() immediately, before any reader actually reads from the stream. That affects existing early-error paths such as fetchMetadataJson's redirect/non-OK/oversized-Content-Length branches, which call response.body?.cancel?.() to discard the response; with this getter installed, those paths now begin buffering/converting the body they are trying to reject, so a large error/redirect response can allocate the full body before throwing. Deferring arrayBuffer() until the first pull/read, or making cancellation before the first read avoid starting it, would preserve the fail-fast behavior.

Useful? React with 👍 / 👎.


return new ReadableStream<Uint8Array>({
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<Uint8Array> {
const stream = createResponseBodyStream(this);
Object.defineProperty(this, "body", {
configurable: true,
value: stream,
});
return stream;
},
});
};

installResponseBodyPolyfill();

export {};
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading