-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathfetch.ts
More file actions
417 lines (386 loc) · 14.5 KB
/
Copy pathfetch.ts
File metadata and controls
417 lines (386 loc) · 14.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
import {
Fetch402InterruptedError,
fetch402 as fetch402Lib,
type PaymentInfo,
type PaymentCredentials,
type PendingPayment,
} from "@getalby/lightning-tools/402";
import { Invoice } from "@getalby/lightning-tools";
import { NWCClient } from "@getalby/sdk";
import { randomUUID } from "node:crypto";
import { writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DetailedError } from "../../utils.js";
const DEFAULT_MAX_AMOUNT_SATS = 5000;
export interface Fetch402Resume {
pendingPayment: PendingPayment;
preimage: string;
}
export interface Fetch402Params {
url: string;
method?: string;
body?: string;
headers?: Record<string, string>;
maxAmountSats?: number;
/**
* A reusable credential returned by a previous fetch. When provided the
* request is authorized with it and NEVER pays again - use it to authorize
* follow-up requests (e.g. polling a long-running job) without re-paying.
*/
credentials?: PaymentCredentials;
/**
* Resume a payment that was interrupted before its preimage was known (the
* `pendingPayment` from a previous fetch error, plus the preimage recovered
* via lookup-invoice). The request is authorized with the rebuilt credential
* and NEVER pays again.
*/
resume?: Fetch402Resume;
/**
* File path to save the response body to instead of returning it inline.
* Binary responses are saved to a temp file even without it; setting it
* forces a file (and chooses its location) for any response, so a large body
* never has to round-trip through the JSON output.
*/
outputPath?: string;
}
export interface Fetch402Result {
/** The response body, when it is text and no --output path was given. */
content?: string;
/** The body base64-encoded, when it is binary and saving it failed. */
contentBase64?: string;
/** Path the body was saved to (binary responses, or --output). */
outputPath?: string;
/** The response Content-Type, reported alongside `outputPath`. */
contentType?: string | null;
/** Size of the body in bytes, reported when it is not inline text. */
sizeBytes?: number;
/** Why the body could not be saved to a file, when that fell back. */
writeError?: string;
payment?: PaymentInfo;
}
function buildRequestOptions(params: Fetch402Params): RequestInit {
const method = params.method?.toUpperCase();
const requestOptions: RequestInit = {
method,
};
if (method && method !== "GET" && method !== "HEAD") {
requestOptions.body = params.body;
// A body is always passed as JSON, so default the Content-Type. The agent
// isn't required to set the header itself but might do so anyway, so only
// add our default when it's absent to avoid a duplicate Content-Type.
const headers = { ...params.headers };
const hasContentType = Object.keys(headers).some(
(key) => key.toLowerCase() === "content-type",
);
if (!hasContentType) {
headers["Content-Type"] = "application/json";
}
requestOptions.headers = headers;
} else if (params.headers) {
requestOptions.headers = params.headers;
}
return requestOptions;
}
export async function fetch402(
client: NWCClient,
params: Fetch402Params,
): Promise<Fetch402Result> {
const requestOptions = buildRequestOptions(params);
const maxAmountSats = params.maxAmountSats ?? DEFAULT_MAX_AMOUNT_SATS;
let result;
try {
result = await fetch402Lib(params.url, requestOptions, {
wallet: client,
maxAmount: maxAmountSats,
credentials: params.credentials,
resume: params.resume,
});
} catch (error) {
if (isFetch402InterruptedError(error)) {
throw toRecoveryError(error);
}
throw error;
}
if (!result.ok) {
// A non-OK response after a payment must not lose the payment metadata -
// with the credential the request can be retried without paying the same
// invoice again.
throw new DetailedError(`fetch returned non-OK status: ${result.status}`, {
...(await readErrorBody(result)),
...(result.payment?.credentials
? paidRecoveryDetails(result.payment)
: {}),
});
}
const bytes = new Uint8Array(await result.arrayBuffer());
const contentType = result.headers.get("content-type");
// Payment metadata attached by the 402 helper: whether a payment was made,
// the amount (amountSat), routing fees (feesPaidMsat, in millisatoshis),
// and the reusable credential. Pass `credentials` back via --credentials
// on a follow-up request to authorize it without paying again. Absent when
// no 402 payment was involved (e.g. an already-open resource).
const payment = result.payment;
if (!params.outputPath) {
const text = decodeText(contentType, bytes);
if (text !== null) {
return { content: text, payment };
}
}
// Reading a binary body as text destroys it (every invalid UTF-8 sequence
// becomes U+FFFD), so it goes to a file and the output carries the path.
const outputPath =
params.outputPath ??
join(tmpdir(), `alby-cli-fetch-${randomUUID()}${extensionFor(contentType)}`);
try {
writeFileSync(outputPath, bytes);
} catch (error) {
// A failed write (ENOSPC, an unwritable --output path) after a payment
// must not throw away the paid response and its reusable credential -
// fall back to returning the body inline, base64-encoded when binary.
const writeError = errorMessage(error);
const text = decodeText(contentType, bytes);
if (text !== null) {
return { content: text, writeError, payment };
}
return {
contentBase64: Buffer.from(bytes).toString("base64"),
contentType,
sizeBytes: bytes.length,
writeError,
payment,
};
}
return { outputPath, contentType, sizeBytes: bytes.length, payment };
}
/**
* Decode the body for inline `content`, or return null when it is binary:
* a declared text content type is decoded as such, and without one the bytes
* qualify only when they are strictly valid UTF-8 (e.g. JSON from an API that
* mislabels or omits the content type).
*/
function decodeText(
contentType: string | null,
bytes: Uint8Array,
): string | null {
const type = contentType?.split(";")[0].trim().toLowerCase() ?? "";
if (
type.startsWith("text/") ||
/[/+](json|xml)$/.test(type) ||
type === "application/x-www-form-urlencoded"
) {
return new TextDecoder().decode(bytes);
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
return null;
}
}
// Filename extension for a saved binary, from the content type's subtype
// (audio/wav -> .wav, application/epub+zip -> .epub). Unknown types get .bin.
function extensionFor(contentType: string | null): string {
const subtype = contentType?.split(";")[0].split("/")[1]?.trim().toLowerCase();
return subtype ? `.${subtype.split("+")[0]}` : ".bin";
}
export interface DryRun402Result {
url: string;
status: number;
payment_required: boolean;
/** Present when the 402 challenge offers a lightning invoice. */
amount_in_sats?: number;
description?: string | null;
/** Present when the challenge offers no lightning invoice. */
message?: string;
/** Body of a non-2xx, non-402 response - often the only diagnostic. */
content?: string;
/** Set when `content` was cut off at the size cap. */
content_truncated?: boolean;
}
const MAX_ERROR_BODY_CHARS = 4096;
/**
* An error body is often the only diagnostic (e.g. a gateway explaining
* exactly why it refused) - surface it, capped, instead of discarding it.
* The body is read through the stream and stops at the cap, so an
* arbitrarily large error body is never buffered whole. A failed read
* surfaces what was read so far rather than throwing, so the payment
* metadata alongside it is never lost.
*/
async function readErrorBody(response: Response): Promise<{
content?: string;
content_truncated?: boolean;
}> {
if (!response.body) return {};
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
try {
while (text.length <= MAX_ERROR_BODY_CHARS) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
} catch {
} finally {
reader.cancel().catch(() => {});
}
if (!text) return {};
return {
content: text.slice(0, MAX_ERROR_BODY_CHARS),
...(text.length > MAX_ERROR_BODY_CHARS
? { content_truncated: true }
: {}),
};
}
const BOLT11_PATTERN = /ln(?:bc|tb|bcrt|tbs)[0-9a-z]+/i;
// Find the lightning invoice a 402 challenge offers. Only the headers are
// checked, matching the pay path in lightning-tools (which never reads the
// 402 body): L402 and MPP carry the invoice in WWW-Authenticate
// (invoice="lnbc..."); lightning-native x402 embeds it in the base64
// Payment-Required header as extra.invoice.
function extractLightningInvoice(response: Response): string | null {
const wwwAuthenticate = response.headers.get("www-authenticate") ?? "";
const headerMatch = wwwAuthenticate.match(
new RegExp(`invoice="(${BOLT11_PATTERN.source})"`, "i"),
);
if (headerMatch) return headerMatch[1];
const paymentRequired = response.headers.get("payment-required");
if (paymentRequired) {
try {
const decoded = Buffer.from(paymentRequired, "base64").toString("utf-8");
const match = decoded.match(BOLT11_PATTERN);
if (match) return match[0];
} catch {
// Not base64 - no usable invoice.
}
}
return null;
}
/**
* Preview what a paid endpoint costs without paying: send the request unpaid
* and report the 402 challenge. Prices on 402index.io can be missing, stale,
* or dynamic - the challenge is the authoritative price at request time. Needs
* no wallet.
*/
export async function dryRun402(
params: Fetch402Params,
): Promise<DryRun402Result> {
const response = await fetch(params.url, buildRequestOptions(params));
if (response.status !== 402) {
return {
url: params.url,
status: response.status,
payment_required: false,
...(response.ok ? {} : await readErrorBody(response)),
} satisfies DryRun402Result;
}
const invoice = extractLightningInvoice(response);
if (invoice) {
// The pattern can match invoice-looking garbage; a challenge whose
// "invoice" doesn't decode offers no usable invoice - fall through.
try {
const { satoshi, description } = new Invoice({ pr: invoice });
return {
url: params.url,
status: response.status,
payment_required: true,
amount_in_sats: satoshi,
description,
} satisfies DryRun402Result;
} catch {}
}
// No lightning invoice offered (e.g. USDC-only x402) - this CLI pays
// lightning only, so point at the l402.space bridge, which settles the
// upstream and charges over lightning.
return {
url: params.url,
status: response.status,
payment_required: true,
message:
"no lightning invoice found in the 402 challenge - try fetching " +
"through the l402.space bridge instead: " +
`https://l402.space/${encodeURIComponent(params.url)}`,
} satisfies DryRun402Result;
}
/**
* Convert the library's Fetch402InterruptedError into a structured CLI error.
*
* A payment was attempted but the flow failed before a response was obtained.
* The CLI deliberately does NOT recover on its own (no wallet polling, no
* automatic retries) - whether and when to reconcile is the caller's call, and
* a CLI silently waiting on an in-flight payment would just look hung. Instead
* the error output carries everything the caller needs to recover without ever
* paying the same invoice twice.
*/
function toRecoveryError(error: Fetch402InterruptedError): DetailedError {
if (error.paid && error.credentials) {
// The invoice was paid but the request after it failed (e.g. a network
// error). The credential is already built - a retry must reuse it. The
// error doesn't carry the library's PaymentInfo, so rebuild the same shape
// from what it does carry.
return new DetailedError(
`payment succeeded but the request failed: ${errorMessage(error.cause ?? error)}`,
paidRecoveryDetails(
{
paid: true,
amountSat: error.amountSat,
feesPaidMsat: error.feesPaidMsat,
preimage: error.preimage,
credentials: error.credentials,
},
error.paymentHash,
),
);
}
// payInvoice itself failed (e.g. an NWC reply timeout). The payment may
// still settle after we exit, so retrying now could pay the same invoice
// twice - surface everything needed to check and resume without re-paying.
return new DetailedError(
`payment did not complete: ${errorMessage(error.cause ?? error)}. ` +
"The payment may still settle - do NOT retry this fetch yet.",
{
paymentRecovery: {
status: "unknown",
paymentHash: error.paymentHash,
pendingPayment: error.pendingPayment,
instructions:
"The payment may still be in flight; retrying this fetch now could " +
"pay twice. First run: lookup-invoice --payment-hash " +
`${error.paymentHash} - if it returns a preimage, the payment ` +
"settled: re-run the same fetch adding: --resume " +
`'{"pendingPayment":<pendingPayment from this error>,"preimage":"<preimage from lookup-invoice>"}' ` +
"to get the content without paying again. If it shows state " +
'"failed", no funds moved and it is safe to re-run the fetch ' +
"normally. If it is still pending or not found, wait and check again.",
},
},
);
}
function paidRecoveryDetails(payment: PaymentInfo, paymentHash?: string) {
return {
paymentRecovery: {
status: "paid",
...(paymentHash ? { paymentHash } : {}),
payment,
instructions:
"The payment already succeeded - do NOT re-run this fetch without " +
"--credentials, or you will pay again. If the failure looks " +
"temporary, re-run the same fetch adding: --credentials " +
`'${JSON.stringify(payment.credentials)}'`,
},
};
}
// The error normally arrives via instanceof, but match on `name` too so a
// serialized/re-thrown copy (the class documents this) is still recognized.
function isFetch402InterruptedError(
error: unknown,
): error is Fetch402InterruptedError {
return (
error instanceof Fetch402InterruptedError ||
(error instanceof Error && error.name === "Fetch402InterruptedError")
);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}