Skip to content

Commit ce434db

Browse files
rolznzclaude
andauthored
fix: save binary fetch responses to a file instead of corrupting them as text (#62)
* fix: save binary fetch responses to a file instead of corrupting them as text Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: never lose a paid response when saving its file fails Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 04e2137 commit ce434db

5 files changed

Lines changed: 242 additions & 14 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@getalby/cli",
33
"description": "CLI for Nostr Wallet Connect (NIP-47) with a few additional useful lightning tools",
44
"repository": "https://github.com/getAlby/cli.git",
5-
"version": "0.10.0",
5+
"version": "0.10.1",
66
"type": "module",
77
"main": "build/index.js",
88
"bin": {

src/commands/fetch.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ export function registerFetch402Command(program: Command) {
101101
.option("-X, --method <method>", "HTTP method (GET, POST, etc.)")
102102
.option("-b, --body <json>", "Request body (JSON string)")
103103
.option("-H, --headers <json>", "Additional headers (JSON string)")
104+
.option(
105+
"-o, --output <path>",
106+
"Save the response body to this file and return the path instead of " +
107+
"inline content. Binary responses (audio, images, ...) are saved to a " +
108+
"temp file automatically; use this to pick the location, or to keep a " +
109+
"large text response out of the JSON output.",
110+
)
104111
.option(
105112
"--dry-run",
106113
"Preview the price without paying: sends the request unpaid and reports " +
@@ -146,6 +153,10 @@ export function registerFetch402Command(program: Command) {
146153
"after",
147154
"\nExample:\n" +
148155
' $ npx @getalby/cli fetch "https://example.com/api" --max-amount 1000 --currency BTC --unit sats --network lightning\n' +
156+
"\nText responses are returned inline as `content`. Binary responses (audio,\n" +
157+
"images, ...) are saved to a temp file - the output then carries `outputPath`,\n" +
158+
"`contentType` and `sizeBytes` instead. Pass -o <path> to choose the file\n" +
159+
"location, or to force any response (e.g. a large text body) to a file.\n" +
149160
"\nA successful response includes a `payment` object with the amount paid\n" +
150161
"(amountSat), routing fees (feesPaidMsat, in millisatoshis) and a reusable\n" +
151162
"`credentials` value. Reuse it to avoid paying again:\n" +
@@ -213,6 +224,7 @@ export function registerFetch402Command(program: Command) {
213224
? parseCredentials(options.credentials)
214225
: undefined,
215226
resume: options.resume ? parseResume(options.resume) : undefined,
227+
outputPath: options.output,
216228
});
217229
output(result);
218230
});

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ program
4545
" $ npx @getalby/cli pay alice@getalby.com --amount 5 --currency USD --network lightning\n" +
4646
' $ npx @getalby/cli receive --amount 2100 --currency BTC --unit sats --network lightning --description "Coffee"',
4747
)
48-
.version("0.10.0")
48+
.version("0.10.1")
4949
.configureHelp({ showGlobalOptions: true })
5050
.option(
5151
"-w, --wallet-name <name>",

src/test/fetch-binary.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { describe, test, expect, vi, afterEach } from "vitest";
2+
import { readFileSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { NWCClient } from "@getalby/sdk";
6+
import { fetch402 } from "../tools/lightning/fetch.js";
7+
8+
// A minimal WAV header: contains bytes (0xAC, 0x88, ...) that are invalid
9+
// UTF-8, so a text decode would corrupt them into U+FFFD.
10+
const WAV_BYTES = new Uint8Array([
11+
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x41, 0x56, 0x45,
12+
0x66, 0x6d, 0x74, 0x20, 0x10, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,
13+
0x44, 0xac, 0x00, 0x00, 0x88, 0x58, 0x01, 0x00,
14+
]);
15+
16+
const URL = "https://example.com/protected";
17+
18+
const client = {} as unknown as NWCClient;
19+
const savedFiles: string[] = [];
20+
21+
function stubResponse(
22+
body: BodyInit,
23+
headers: Record<string, string> = {},
24+
): void {
25+
vi.stubGlobal(
26+
"fetch",
27+
vi.fn().mockResolvedValue(new Response(body, { status: 200, headers })),
28+
);
29+
}
30+
31+
afterEach(() => {
32+
vi.unstubAllGlobals();
33+
for (const file of savedFiles.splice(0)) {
34+
rmSync(file, { force: true });
35+
}
36+
});
37+
38+
describe("fetch binary responses", () => {
39+
test("saves a binary body to a temp file instead of returning mangled text", async () => {
40+
stubResponse(WAV_BYTES, { "content-type": "audio/wav" });
41+
42+
const result = await fetch402(client, { url: URL });
43+
savedFiles.push(result.outputPath!);
44+
45+
expect(result.content).toBeUndefined();
46+
expect(result.outputPath).toContain(tmpdir());
47+
expect(result.outputPath).toMatch(/\.wav$/);
48+
expect(result.contentType).toBe("audio/wav");
49+
expect(result.sizeBytes).toBe(WAV_BYTES.length);
50+
// The saved bytes are the original response, byte for byte.
51+
expect(new Uint8Array(readFileSync(result.outputPath!))).toEqual(WAV_BYTES);
52+
});
53+
54+
test("saves an unlabeled binary body with a .bin extension", async () => {
55+
stubResponse(WAV_BYTES);
56+
57+
const result = await fetch402(client, { url: URL });
58+
savedFiles.push(result.outputPath!);
59+
60+
expect(result.outputPath).toMatch(/\.bin$/);
61+
expect(new Uint8Array(readFileSync(result.outputPath!))).toEqual(WAV_BYTES);
62+
});
63+
64+
test("returns a declared-text body inline even when not valid UTF-8", async () => {
65+
stubResponse(new Uint8Array([0x68, 0x69, 0xff]), {
66+
"content-type": "text/plain; charset=utf-8",
67+
});
68+
69+
const result = await fetch402(client, { url: URL });
70+
71+
expect(result.outputPath).toBeUndefined();
72+
expect(result.content).toBe("hi�");
73+
});
74+
75+
test("returns an unlabeled body inline when it decodes cleanly as UTF-8", async () => {
76+
// Bytes without a content-type header: a string body would get an
77+
// automatic text/plain and never reach the sniffing path.
78+
stubResponse(new TextEncoder().encode('{"ok":true}'));
79+
80+
const result = await fetch402(client, { url: URL });
81+
82+
expect(result.outputPath).toBeUndefined();
83+
expect(result.content).toBe('{"ok":true}');
84+
});
85+
86+
test("--output saves any body, text included, to the given path", async () => {
87+
stubResponse('{"ok":true}', { "content-type": "application/json" });
88+
const outputPath = join(tmpdir(), `fetch-binary-test-${process.pid}.json`);
89+
90+
const result = await fetch402(client, { url: URL, outputPath });
91+
savedFiles.push(outputPath);
92+
93+
expect(result.content).toBeUndefined();
94+
expect(result.outputPath).toBe(outputPath);
95+
expect(readFileSync(outputPath, "utf-8")).toBe('{"ok":true}');
96+
});
97+
98+
test("falls back to base64 inline when a binary body cannot be saved", async () => {
99+
// A failed write must not throw away the (possibly paid) response.
100+
stubResponse(WAV_BYTES, { "content-type": "audio/wav" });
101+
const outputPath = join(tmpdir(), "no-such-dir", "out.wav");
102+
103+
const result = await fetch402(client, { url: URL, outputPath });
104+
105+
expect(result.outputPath).toBeUndefined();
106+
expect(result.writeError).toContain("ENOENT");
107+
expect(result.contentType).toBe("audio/wav");
108+
expect(
109+
new Uint8Array(Buffer.from(result.contentBase64!, "base64")),
110+
).toEqual(WAV_BYTES);
111+
});
112+
113+
test("falls back to inline text when a text body cannot be saved", async () => {
114+
stubResponse('{"ok":true}', { "content-type": "application/json" });
115+
const outputPath = join(tmpdir(), "no-such-dir", "out.json");
116+
117+
const result = await fetch402(client, { url: URL, outputPath });
118+
119+
expect(result.outputPath).toBeUndefined();
120+
expect(result.writeError).toContain("ENOENT");
121+
expect(result.content).toBe('{"ok":true}');
122+
});
123+
});

src/tools/lightning/fetch.ts

Lines changed: 105 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
} from "@getalby/lightning-tools/402";
88
import { Invoice } from "@getalby/lightning-tools";
99
import { NWCClient } from "@getalby/sdk";
10+
import { randomUUID } from "node:crypto";
11+
import { writeFileSync } from "node:fs";
12+
import { tmpdir } from "node:os";
13+
import { join } from "node:path";
1014
import { DetailedError } from "../../utils.js";
1115

1216
const DEFAULT_MAX_AMOUNT_SATS = 5000;
@@ -35,6 +39,29 @@ export interface Fetch402Params {
3539
* and NEVER pays again.
3640
*/
3741
resume?: Fetch402Resume;
42+
/**
43+
* File path to save the response body to instead of returning it inline.
44+
* Binary responses are saved to a temp file even without it; setting it
45+
* forces a file (and chooses its location) for any response, so a large body
46+
* never has to round-trip through the JSON output.
47+
*/
48+
outputPath?: string;
49+
}
50+
51+
export interface Fetch402Result {
52+
/** The response body, when it is text and no --output path was given. */
53+
content?: string;
54+
/** The body base64-encoded, when it is binary and saving it failed. */
55+
contentBase64?: string;
56+
/** Path the body was saved to (binary responses, or --output). */
57+
outputPath?: string;
58+
/** The response Content-Type, reported alongside `outputPath`. */
59+
contentType?: string | null;
60+
/** Size of the body in bytes, reported when it is not inline text. */
61+
sizeBytes?: number;
62+
/** Why the body could not be saved to a file, when that fell back. */
63+
writeError?: string;
64+
payment?: PaymentInfo;
3865
}
3966

4067
function buildRequestOptions(params: Fetch402Params): RequestInit {
@@ -63,7 +90,10 @@ function buildRequestOptions(params: Fetch402Params): RequestInit {
6390
return requestOptions;
6491
}
6592

66-
export async function fetch402(client: NWCClient, params: Fetch402Params) {
93+
export async function fetch402(
94+
client: NWCClient,
95+
params: Fetch402Params,
96+
): Promise<Fetch402Result> {
6797
const requestOptions = buildRequestOptions(params);
6898
const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS;
6999

@@ -82,28 +112,91 @@ export async function fetch402(client: NWCClient, params: Fetch402Params) {
82112
throw error;
83113
}
84114

85-
const responseContent = await result.text();
86115
if (!result.ok) {
87116
// A non-OK response after a payment must not lose the payment metadata -
88117
// with the credential the request can be retried without paying the same
89118
// invoice again.
90119
throw new DetailedError(
91-
`fetch returned non-OK status: ${result.status} ${responseContent}`,
120+
`fetch returned non-OK status: ${result.status} ${await result.text()}`,
92121
result.payment?.credentials
93122
? paidRecoveryDetails(result.payment)
94123
: undefined,
95124
);
96125
}
97126

98-
return {
99-
content: responseContent,
100-
// Payment metadata attached by the 402 helper: whether a payment was made,
101-
// the amount (amountSat), routing fees (feesPaidMsat, in millisatoshis),
102-
// and the reusable credential. Pass `credentials` back via --credentials
103-
// on a follow-up request to authorize it without paying again. Absent when
104-
// no 402 payment was involved (e.g. an already-open resource).
105-
payment: result.payment,
106-
};
127+
const bytes = new Uint8Array(await result.arrayBuffer());
128+
const contentType = result.headers.get("content-type");
129+
// Payment metadata attached by the 402 helper: whether a payment was made,
130+
// the amount (amountSat), routing fees (feesPaidMsat, in millisatoshis),
131+
// and the reusable credential. Pass `credentials` back via --credentials
132+
// on a follow-up request to authorize it without paying again. Absent when
133+
// no 402 payment was involved (e.g. an already-open resource).
134+
const payment = result.payment;
135+
136+
if (!params.outputPath) {
137+
const text = decodeText(contentType, bytes);
138+
if (text !== null) {
139+
return { content: text, payment };
140+
}
141+
}
142+
143+
// Reading a binary body as text destroys it (every invalid UTF-8 sequence
144+
// becomes U+FFFD), so it goes to a file and the output carries the path.
145+
const outputPath =
146+
params.outputPath ??
147+
join(tmpdir(), `alby-cli-fetch-${randomUUID()}${extensionFor(contentType)}`);
148+
try {
149+
writeFileSync(outputPath, bytes);
150+
} catch (error) {
151+
// A failed write (ENOSPC, an unwritable --output path) after a payment
152+
// must not throw away the paid response and its reusable credential -
153+
// fall back to returning the body inline, base64-encoded when binary.
154+
const writeError = errorMessage(error);
155+
const text = decodeText(contentType, bytes);
156+
if (text !== null) {
157+
return { content: text, writeError, payment };
158+
}
159+
return {
160+
contentBase64: Buffer.from(bytes).toString("base64"),
161+
contentType,
162+
sizeBytes: bytes.length,
163+
writeError,
164+
payment,
165+
};
166+
}
167+
return { outputPath, contentType, sizeBytes: bytes.length, payment };
168+
}
169+
170+
/**
171+
* Decode the body for inline `content`, or return null when it is binary:
172+
* a declared text content type is decoded as such, and without one the bytes
173+
* qualify only when they are strictly valid UTF-8 (e.g. JSON from an API that
174+
* mislabels or omits the content type).
175+
*/
176+
function decodeText(
177+
contentType: string | null,
178+
bytes: Uint8Array,
179+
): string | null {
180+
const type = contentType?.split(";")[0].trim().toLowerCase() ?? "";
181+
if (
182+
type.startsWith("text/") ||
183+
/[/+](json|xml)$/.test(type) ||
184+
type === "application/x-www-form-urlencoded"
185+
) {
186+
return new TextDecoder().decode(bytes);
187+
}
188+
try {
189+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
190+
} catch {
191+
return null;
192+
}
193+
}
194+
195+
// Filename extension for a saved binary, from the content type's subtype
196+
// (audio/wav -> .wav, application/epub+zip -> .epub). Unknown types get .bin.
197+
function extensionFor(contentType: string | null): string {
198+
const subtype = contentType?.split(";")[0].split("/")[1]?.trim().toLowerCase();
199+
return subtype ? `.${subtype.split("+")[0]}` : ".bin";
107200
}
108201

109202
export interface DryRun402Result {

0 commit comments

Comments
 (0)