@readfetch/client 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -2
- package/config.mjs +81 -0
- package/index.mjs +116 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @readfetch/client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
x402 REST client for [readfetch.com](https://readfetch.com): **`probe`** (free) and **`readfetchText`** (paid `/v1/text`).
|
|
4
|
+
|
|
5
|
+
Source of truth in the private service checkout: `tools/client-js`. Published to GitHub with `./scripts/publish-client-js.sh`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @readfetch/client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export EVM_PRIVATE_KEY=0x… # not needed for probe()
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```javascript
|
|
20
|
+
import { probe, probeWithMeta, readfetchText, submitFeedback } from "@readfetch/client";
|
|
21
|
+
|
|
22
|
+
const { data: check, requestId } = await probeWithMeta("https://example.com");
|
|
23
|
+
if (requestId) console.error(`X-Request-Id: ${requestId}`);
|
|
24
|
+
if (!check.challenge) {
|
|
25
|
+
const page = await readfetchText("https://example.com");
|
|
26
|
+
}
|
|
27
|
+
// Integrator issues (not billing):
|
|
28
|
+
// await submitFeedback(requestId, "integration", "402 confused my parser");
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Environment
|
|
32
|
+
|
|
33
|
+
| Variable | Default | Purpose |
|
|
34
|
+
|----------|---------|---------|
|
|
35
|
+
| `EVM_PRIVATE_KEY` / `X402_BUYER_KEY` | — | Required before paid calls |
|
|
36
|
+
| `READFETCH_BASE_URL` | `https://readfetch.com` | Production host only unless dev |
|
|
37
|
+
| `READFETCH_MAX_PAYMENT_USD` | `1` | Per-payment spend cap |
|
|
38
|
+
| `READFETCH_DEV=1` | off | Allow `localhost` / `127.0.0.1` + Sepolia (`eip155:84532`) |
|
|
39
|
+
|
|
40
|
+
Framework wrappers (LangChain.js, OpenAI Agents SDK): see [readfetch/readfetch examples](https://github.com/readfetch/readfetch/tree/main/examples).
|
|
41
|
+
|
|
42
|
+
MCP hosts: use [`@readfetch/mcp-x402-proxy`](https://github.com/readfetch/mcp-x402-proxy) instead — they cannot attach `PAYMENT-SIGNATURE` on `tools/call`.
|
|
43
|
+
|
|
44
|
+
**Feedback:** after a confusing call, `POST /v1/feedback` with the response's `X-Request-Id` (or MCP `readfetch_feedback`). Not for billing — use claims. See [readfetch.com/docs#feedback](https://readfetch.com/docs#feedback).
|
package/config.mjs
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base URL and env validation for readfetch REST clients.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const PRODUCTION_HOSTS = new Set(["readfetch.com", "www.readfetch.com"]);
|
|
6
|
+
const DEV_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
7
|
+
|
|
8
|
+
export function normalizeHostname(hostname) {
|
|
9
|
+
return hostname.replace(/\.+$/, "").toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isProductionHost(hostname) {
|
|
13
|
+
return PRODUCTION_HOSTS.has(normalizeHostname(hostname));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isDevHost(hostname) {
|
|
17
|
+
return DEV_HOSTS.has(normalizeHostname(hostname));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
21
|
+
export function readfetchBase(env = process.env) {
|
|
22
|
+
const raw = (env.READFETCH_BASE_URL || "https://readfetch.com").replace(/\/$/, "");
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error("READFETCH_BASE_URL is not a valid URL");
|
|
28
|
+
}
|
|
29
|
+
if (isProductionHost(url.hostname)) {
|
|
30
|
+
if (url.protocol !== "https:") {
|
|
31
|
+
throw new Error("READFETCH_BASE_URL must use https for readfetch.com");
|
|
32
|
+
}
|
|
33
|
+
return raw;
|
|
34
|
+
}
|
|
35
|
+
if (env.READFETCH_DEV === "1" && isDevHost(url.hostname)) {
|
|
36
|
+
return raw;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
"READFETCH_BASE_URL must be https://readfetch.com (or localhost with READFETCH_DEV=1)",
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
44
|
+
export function parseMaxPaymentUsd(env = process.env) {
|
|
45
|
+
const raw = env.READFETCH_MAX_PAYMENT_USD?.trim() || "1";
|
|
46
|
+
if (!/^\d+(\.\d{1,6})?$/.test(raw)) {
|
|
47
|
+
throw new Error("READFETCH_MAX_PAYMENT_USD must be a decimal amount");
|
|
48
|
+
}
|
|
49
|
+
const n = Number(raw);
|
|
50
|
+
if (!Number.isFinite(n) || n <= 0 || n > 100) {
|
|
51
|
+
throw new Error("READFETCH_MAX_PAYMENT_USD must be between 0 and 100");
|
|
52
|
+
}
|
|
53
|
+
return raw;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
57
|
+
export function x402Network(env = process.env) {
|
|
58
|
+
const base = readfetchBase(env);
|
|
59
|
+
try {
|
|
60
|
+
const host = new URL(base).hostname;
|
|
61
|
+
if (env.READFETCH_DEV === "1" && isDevHost(host)) {
|
|
62
|
+
return "eip155:84532";
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
/* readfetchBase already validated */
|
|
66
|
+
}
|
|
67
|
+
return "eip155:8453";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
71
|
+
export function loadAccountFromEnv(env = process.env) {
|
|
72
|
+
const raw = env.EVM_PRIVATE_KEY?.trim() || env.X402_BUYER_KEY?.trim();
|
|
73
|
+
if (!raw) {
|
|
74
|
+
throw new Error("Set EVM_PRIVATE_KEY (Base wallet with USDC)");
|
|
75
|
+
}
|
|
76
|
+
const hex = raw.startsWith("0x") ? raw : `0x${raw}`;
|
|
77
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) {
|
|
78
|
+
throw new Error("EVM_PRIVATE_KEY must be 64 hex bytes (optional 0x prefix)");
|
|
79
|
+
}
|
|
80
|
+
return hex;
|
|
81
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
|
|
2
|
+
import { ExactEvmScheme } from "@x402/evm";
|
|
3
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
4
|
+
import {
|
|
5
|
+
loadAccountFromEnv,
|
|
6
|
+
parseMaxPaymentUsd,
|
|
7
|
+
readfetchBase,
|
|
8
|
+
x402Network,
|
|
9
|
+
} from "./config.mjs";
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
isDevHost,
|
|
13
|
+
isProductionHost,
|
|
14
|
+
loadAccountFromEnv,
|
|
15
|
+
parseMaxPaymentUsd,
|
|
16
|
+
readfetchBase,
|
|
17
|
+
x402Network,
|
|
18
|
+
} from "./config.mjs";
|
|
19
|
+
|
|
20
|
+
const TIMEOUT_MS = 120_000;
|
|
21
|
+
|
|
22
|
+
const FEEDBACK_CATEGORIES = new Set(["integration", "docs", "site", "other"]);
|
|
23
|
+
|
|
24
|
+
let fetchWithPayment;
|
|
25
|
+
|
|
26
|
+
/** @param {NodeJS.ProcessEnv} [env] */
|
|
27
|
+
export function paidFetch(env = process.env) {
|
|
28
|
+
if (!fetchWithPayment) {
|
|
29
|
+
const account = privateKeyToAccount(loadAccountFromEnv(env));
|
|
30
|
+
fetchWithPayment = wrapFetchWithPaymentFromConfig(globalThis.fetch, {
|
|
31
|
+
schemes: [{ network: x402Network(env), client: new ExactEvmScheme(account) }],
|
|
32
|
+
spendControls: { maxAmountPerPayment: `$${parseMaxPaymentUsd(env)}` },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return fetchWithPayment;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `x-request-id` from any readfetch JSON response (for `submitFeedback`). */
|
|
39
|
+
export function requestIdFromResponse(response) {
|
|
40
|
+
return response.headers.get("x-request-id") ?? response.headers.get("X-Request-Id");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Free route — call before paying for `/v1/text`. Returns body only. */
|
|
44
|
+
export async function probe(url, env = process.env) {
|
|
45
|
+
const { data } = await probeWithMeta(url, env);
|
|
46
|
+
return data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Like `probe`, but includes `requestId` from response headers. */
|
|
50
|
+
export async function probeWithMeta(url, env = process.env) {
|
|
51
|
+
const response = await fetch(`${readfetchBase(env)}/v1/probe`, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "content-type": "application/json" },
|
|
54
|
+
body: JSON.stringify({ url }),
|
|
55
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
56
|
+
});
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
throw new Error(`${response.status}: ${text.slice(0, 400)}`);
|
|
60
|
+
}
|
|
61
|
+
return { data: await response.json(), requestId: requestIdFromResponse(response) };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** POST /v1/feedback — free; bind with `requestId` from a prior call's `X-Request-Id`. */
|
|
65
|
+
export async function submitFeedback(requestId, category, message, env = process.env) {
|
|
66
|
+
const id = String(requestId ?? "").trim();
|
|
67
|
+
const cat = String(category ?? "").trim();
|
|
68
|
+
const msg = String(message ?? "").trim();
|
|
69
|
+
if (!id) throw new Error("request_id is required");
|
|
70
|
+
if (!FEEDBACK_CATEGORIES.has(cat)) {
|
|
71
|
+
throw new Error("category must be integration, docs, site, or other");
|
|
72
|
+
}
|
|
73
|
+
if (!msg || msg.length > 500) {
|
|
74
|
+
throw new Error("message is required (max 500 characters)");
|
|
75
|
+
}
|
|
76
|
+
const response = await fetch(`${readfetchBase(env)}/v1/feedback`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: { "content-type": "application/json" },
|
|
79
|
+
body: JSON.stringify({ request_id: id, category: cat, message: msg }),
|
|
80
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
81
|
+
});
|
|
82
|
+
const text = await response.text();
|
|
83
|
+
if (response.status !== 202) {
|
|
84
|
+
throw new Error(`${response.status}: ${text.slice(0, 400)}`);
|
|
85
|
+
}
|
|
86
|
+
return JSON.parse(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** POST /v1/text — x402 402 handling is automatic. */
|
|
90
|
+
export async function readfetchText(url, env = process.env) {
|
|
91
|
+
const response = await paidFetch(env)(`${readfetchBase(env)}/v1/text`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ url }),
|
|
95
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
const text = await response.text();
|
|
99
|
+
throw new Error(`${response.status}: ${text.slice(0, 400)}`);
|
|
100
|
+
}
|
|
101
|
+
return response.json();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** OpenAI Agents SDK — attach `execute` to your host's tool wrapper. */
|
|
105
|
+
export const readfetchTextToolDefinition = {
|
|
106
|
+
type: "function",
|
|
107
|
+
name: "readfetch_text",
|
|
108
|
+
description:
|
|
109
|
+
"Read a public URL and return main article text as Markdown with metadata (readfetch.com).",
|
|
110
|
+
parameters: {
|
|
111
|
+
type: "object",
|
|
112
|
+
required: ["url"],
|
|
113
|
+
properties: { url: { type: "string", format: "uri" } },
|
|
114
|
+
},
|
|
115
|
+
execute: async ({ url }) => readfetchText(url),
|
|
116
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@readfetch/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "x402 REST client for readfetch.com (probe, text, …)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/readfetch/client-js.git"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./index.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": ["index.mjs", "config.mjs", "README.md"],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": { "node": ">=20" },
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node test-static.mjs",
|
|
21
|
+
"prepublishOnly": "node --check index.mjs && node --check config.mjs && node test-static.mjs"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@x402/evm": "2.29.0",
|
|
25
|
+
"@x402/fetch": "2.29.0",
|
|
26
|
+
"viem": "2.57.13"
|
|
27
|
+
}
|
|
28
|
+
}
|