@@ -7,6 +7,10 @@ import {
77} from "@getalby/lightning-tools/402" ;
88import { Invoice } from "@getalby/lightning-tools" ;
99import { 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" ;
1014import { DetailedError } from "../../utils.js" ;
1115
1216const 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
4067function 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+ / [ / + ] ( j s o n | x m l ) $ / . 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
109202export interface DryRun402Result {
0 commit comments