@peaje/proxy 0.1.2 → 0.2.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/LICENSE +21 -0
- package/README.md +16 -2
- package/dist/{chunk-IU5YMR4J.js → chunk-SWB3HJVY.js} +162 -34
- package/dist/express.cjs +156 -33
- package/dist/express.js +5 -2
- package/dist/hono.cjs +170 -35
- package/dist/hono.d.cts +3 -0
- package/dist/hono.d.ts +3 -0
- package/dist/hono.js +10 -2
- package/dist/index.cjs +161 -34
- package/dist/index.d.cts +23 -2
- package/dist/index.d.ts +23 -2
- package/dist/index.js +1 -1
- package/dist/vercel.cjs +158 -32
- package/dist/vercel.js +8 -1
- package/package.json +5 -2
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandra Carrillo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -90,9 +90,21 @@ export default { fetch: async (req: Request) => (await peaje(req)) ?? app(req) }
|
|
|
90
90
|
|
|
91
91
|
- The catalog paths: `/llms.txt`, `/openapi.json`, `/mcp`, `/.well-known/*` Peaje files, `/developers`, `/docs` and the rest of the kit list.
|
|
92
92
|
- `/r/*` priced links and `/checkout_sessions/*`.
|
|
93
|
-
- Your priced API routes from the dashboard (`GET /api/forecast`, `/api/history/:city`, `/api/batch/*`), matching the method. Catch-alls at the root (`/*`, `/:id`) are never forwarded from your domain: they would send the whole site to the gateway.
|
|
93
|
+
- Your priced API routes from the dashboard (`GET /api/forecast`, `/api/history/:city`, `/api/batch/*`), matching the method, but only under `apiPrefixes` (default `['/api/', '/v1/']`). Catch-alls at the root (`/*`, `/:id`) are never forwarded from your domain: they would send the whole site to the gateway.
|
|
94
94
|
|
|
95
|
-
The forwarded request keeps method, body and
|
|
95
|
+
The forwarded request keeps method, body, query and `authorization` (the MPP credential), and carries `x-forwarded-host` and `x-forwarded-proto` so the 402 challenge names your domain (strict x402 clients abort when it does not). It leaves out the `cookie` header: your visitors' session cookies never leave your domain, and a priced route's handler no longer receives cookies through Peaje. If the gateway is unreachable, the catalog paths are still forwarded; priced API routes resume when the manifest loads.
|
|
96
|
+
|
|
97
|
+
### What the manifest cannot do (since 0.2.0)
|
|
98
|
+
|
|
99
|
+
- Add fixed paths or prefixes. Those come only from the list bundled in this package; anything extra in the manifest is ignored.
|
|
100
|
+
- Forward an API route outside `apiPrefixes`. A route priced elsewhere (`GET /weather/:city`) keeps charging at the gateway URL and you get one warning in the logs naming it. To charge it on your domain: `{ slug, apiPrefixes: ['/api/', '/v1/', '/weather/'] }`.
|
|
101
|
+
- Touch the root `/` or reserved app paths, whatever `apiPrefixes` says: `/login`, `/logout`, `/signin`, `/signup`, `/register`, `/auth`, `/oauth`, `/session`, `/account`, `/admin`, `/dashboard`, `/api/auth`, `/_next`, `/_vercel`, `/static`, `/assets`, `/favicon.ico`, `/robots.txt`, `/sitemap.xml` and everything under them.
|
|
102
|
+
|
|
103
|
+
Upgrading from 0.1.x: if your priced routes live outside `/api/` and `/v1/`, add their prefix to `apiPrefixes` before you deploy 0.2.0.
|
|
104
|
+
|
|
105
|
+
## Bot visits (since 0.2.1)
|
|
106
|
+
|
|
107
|
+
When a known AI bot (GPTBot, ClaudeBot, OAI-SearchBot, PerplexityBot, Googlebot) reads a page your app serves itself, such as your home page or a blog post, the proxy tells the gateway in the background, so the read shows up in your agent visits. The gateway never sees those requests otherwise. It sends only the path, without the query string, and the bot's user agent: no IP, no cookies, and visits from people are never reported. The Vercel adapter runs it in `waitUntil`, Hono on Workers in `executionCtx.waitUntil`, and `peajeFetch(request, waitUntil)` takes one; elsewhere it runs alongside the response with a 3 s timeout. Turn it off with `{ slug, reportBotVisits: false }`.
|
|
96
108
|
|
|
97
109
|
## Options
|
|
98
110
|
|
|
@@ -101,6 +113,8 @@ The forwarded request keeps method, body and query, and carries `x-forwarded-hos
|
|
|
101
113
|
| `slug` | required, the business slug from your dashboard |
|
|
102
114
|
| `secret` | `process.env.PEAJE_ORIGIN_SECRET` |
|
|
103
115
|
| `gateway` | `process.env.PEAJE_GATEWAY_URL` or `https://api.usepeaje.com` |
|
|
116
|
+
| `apiPrefixes` | `['/api/', '/v1/']`: where your priced API routes live; the root and reserved paths are never accepted |
|
|
117
|
+
| `reportBotVisits` | `true`: tell the gateway when an AI bot reads a page of your app, for your agent visits |
|
|
104
118
|
|
|
105
119
|
## License
|
|
106
120
|
|
|
@@ -25,9 +25,54 @@ var RUTAS_DINAMICAS = [
|
|
|
25
25
|
{ path: "/r/:slug", prefijo: "/r/", nota: "links con precio: el 402 en tu dominio" },
|
|
26
26
|
{ path: "/checkout_sessions/:id", prefijo: "/checkout_sessions/", nota: "leer una sesi\xF3n de checkout ACP" }
|
|
27
27
|
];
|
|
28
|
+
var PREFIJOS_API_POR_DEFECTO = ["/api/", "/v1/"];
|
|
29
|
+
var PATHS_RESERVADOS = [
|
|
30
|
+
"/",
|
|
31
|
+
"/login",
|
|
32
|
+
"/logout",
|
|
33
|
+
"/signin",
|
|
34
|
+
"/signout",
|
|
35
|
+
"/signup",
|
|
36
|
+
"/sign-in",
|
|
37
|
+
"/sign-out",
|
|
38
|
+
"/sign-up",
|
|
39
|
+
"/register",
|
|
40
|
+
"/auth",
|
|
41
|
+
"/oauth",
|
|
42
|
+
"/session",
|
|
43
|
+
"/account",
|
|
44
|
+
"/admin",
|
|
45
|
+
"/dashboard",
|
|
46
|
+
"/_next",
|
|
47
|
+
"/_vercel",
|
|
48
|
+
"/api/auth",
|
|
49
|
+
"/static",
|
|
50
|
+
"/assets",
|
|
51
|
+
"/favicon.ico",
|
|
52
|
+
"/robots.txt",
|
|
53
|
+
"/sitemap.xml"
|
|
54
|
+
];
|
|
28
55
|
var PEAJE_FETCH_HEADER = "x-peaje-fetch";
|
|
29
56
|
var PEAJE_HOST_HEADER = "x-peaje-forwarded-host";
|
|
30
57
|
|
|
58
|
+
// ../shared/src/kit/bots.ts
|
|
59
|
+
var BOTS_DE_VISITA = [
|
|
60
|
+
{ patron: /OAI-SearchBot/i, tipo: "openai-searchbot" },
|
|
61
|
+
{ patron: /PerplexityBot/i, tipo: "perplexitybot" },
|
|
62
|
+
{ patron: /Googlebot/i, tipo: "googlebot" },
|
|
63
|
+
{ patron: /ClaudeBot|anthropic-ai/i, tipo: "claudebot" },
|
|
64
|
+
{ patron: /GPTBot/i, tipo: "gptbot" }
|
|
65
|
+
];
|
|
66
|
+
function tipoDeBot(ua) {
|
|
67
|
+
if (!ua) return null;
|
|
68
|
+
for (const b of BOTS_DE_VISITA) if (b.patron.test(ua)) return b.tipo;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
var PEAJE_VERIFICADOR_HEADER = "x-peaje-verificador";
|
|
72
|
+
var NO_ES_PAGINA = /\.(png|jpe?g|gif|webp|avif|svg|ico|css|m?js|map|woff2?|ttf|otf|eot|mp4|webm|mp3|wav)$/i;
|
|
73
|
+
var MAX_PATH_LECTURA = 500;
|
|
74
|
+
var MAX_UA_LECTURA = 300;
|
|
75
|
+
|
|
31
76
|
// ../shared/src/kit/proxy-runtime.ts
|
|
32
77
|
var PEAJE_ORIGIN_HEADER = "x-peaje-origin";
|
|
33
78
|
function iguales(a, b) {
|
|
@@ -36,6 +81,38 @@ function iguales(a, b) {
|
|
|
36
81
|
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
37
82
|
return d === 0;
|
|
38
83
|
}
|
|
84
|
+
var HEADERS_NO_REENVIADOS = ["cookie"];
|
|
85
|
+
function esReservado(path) {
|
|
86
|
+
const canon = pathCanonico(path).toLowerCase();
|
|
87
|
+
return PATHS_RESERVADOS.some((r) => r === "/" ? canon === "/" : canon === r || canon.startsWith(`${r}/`));
|
|
88
|
+
}
|
|
89
|
+
function prefijosApiValidos(prefijos = PREFIJOS_API_POR_DEFECTO) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const crudo of prefijos) {
|
|
92
|
+
const segs = segmentosCanonicos(String(crudo ?? "")).map((s) => s.toLowerCase());
|
|
93
|
+
if (segs.length === 0) continue;
|
|
94
|
+
const path = `/${segs.join("/")}`;
|
|
95
|
+
if (esReservado(path)) continue;
|
|
96
|
+
if (!out.includes(`${path}/`)) out.push(`${path}/`);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function patronPermitido(patron, prefijos) {
|
|
101
|
+
if (!patronReenviable(patron)) return false;
|
|
102
|
+
const segs = patron.split("/").filter(Boolean);
|
|
103
|
+
const literal = (s) => s !== "*" && !s.startsWith(":");
|
|
104
|
+
const bajoPrefijo = prefijos.some((pre) => {
|
|
105
|
+
const ps = pre.split("/").filter(Boolean);
|
|
106
|
+
return segs.length > ps.length && ps.every((s, i) => literal(segs[i]) && segs[i].toLowerCase() === s);
|
|
107
|
+
});
|
|
108
|
+
if (!bajoPrefijo) return false;
|
|
109
|
+
const literales = [];
|
|
110
|
+
for (const s of segs) {
|
|
111
|
+
if (!literal(s)) break;
|
|
112
|
+
literales.push(s);
|
|
113
|
+
}
|
|
114
|
+
return !esReservado(`/${literales.join("/")}`);
|
|
115
|
+
}
|
|
39
116
|
function coincidePatron(patron, pathname) {
|
|
40
117
|
const p = patron.split("/").filter(Boolean);
|
|
41
118
|
const parts = segmentosCanonicos(pathname);
|
|
@@ -72,50 +149,77 @@ function patronReenviable(patron) {
|
|
|
72
149
|
if (segs.length === 0) return false;
|
|
73
150
|
return !(segs.length === 1 && (segs[0] === "*" || segs[0].startsWith(":")));
|
|
74
151
|
}
|
|
152
|
+
function lecturaDeBot(input) {
|
|
153
|
+
const method = input.method.toUpperCase();
|
|
154
|
+
if (method !== "GET" && method !== "HEAD") return null;
|
|
155
|
+
const h = input.headers;
|
|
156
|
+
if (h.has(PEAJE_ORIGIN_HEADER) || h.has(PEAJE_FETCH_HEADER) || h.has(PEAJE_VERIFICADOR_HEADER)) return null;
|
|
157
|
+
const ua = h.get("user-agent");
|
|
158
|
+
if (!ua || !tipoDeBot(ua)) return null;
|
|
159
|
+
const path = input.pathname;
|
|
160
|
+
if (!path.startsWith("/") || path.startsWith("/_next/") || path.startsWith("/_vercel/") || NO_ES_PAGINA.test(path)) return null;
|
|
161
|
+
return { path: path.slice(0, MAX_PATH_LECTURA), method, userAgent: ua.slice(0, MAX_UA_LECTURA) };
|
|
162
|
+
}
|
|
75
163
|
function crearProxyRuntime(opciones) {
|
|
76
164
|
const base = opciones.base.replace(/\/+$/, "");
|
|
77
165
|
const ttl = opciones.ttlMs ?? 6e4;
|
|
78
166
|
const fetchImpl = opciones.fetchImpl ?? fetch;
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
patrones: opciones.respaldo?.patrones ?? []
|
|
84
|
-
};
|
|
167
|
+
const rutas = [...opciones.fijas?.rutas ?? RUTAS_PROXY.map((r) => r.path)];
|
|
168
|
+
const prefijos = [...opciones.fijas?.prefijos ?? RUTAS_DINAMICAS.map((r) => r.prefijo)];
|
|
169
|
+
const prefijosApi = prefijosApiValidos(opciones.prefijosApi ?? PREFIJOS_API_POR_DEFECTO);
|
|
170
|
+
let cache = { at: 0, patrones: [] };
|
|
85
171
|
let refrescando = null;
|
|
86
172
|
async function refrescar() {
|
|
87
173
|
try {
|
|
88
174
|
const res = await fetchImpl(`${base}/kit/manifest.json`, { signal: AbortSignal.timeout(5e3) });
|
|
89
175
|
if (!res.ok) throw new Error(String(res.status));
|
|
90
176
|
const m = await res.json();
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
rutas: Array.isArray(m.rutas) ? m.rutas : cache.rutas,
|
|
94
|
-
prefijos: Array.isArray(m.prefijos) ? m.prefijos : cache.prefijos,
|
|
95
|
-
patrones: Array.isArray(m.patrones) ? m.patrones : cache.patrones
|
|
96
|
-
};
|
|
177
|
+
const patrones2 = Array.isArray(m.patrones) ? m.patrones.filter((p) => typeof p?.method === "string" && typeof p?.path === "string") : cache.patrones;
|
|
178
|
+
cache = { at: Date.now(), patrones: patrones2 };
|
|
97
179
|
} catch {
|
|
98
180
|
cache = { ...cache, at: Date.now() };
|
|
99
181
|
} finally {
|
|
100
182
|
refrescando = null;
|
|
101
183
|
}
|
|
102
184
|
}
|
|
103
|
-
async function
|
|
185
|
+
async function patrones() {
|
|
104
186
|
const vencido = Date.now() - cache.at >= ttl;
|
|
105
187
|
if (vencido) {
|
|
106
188
|
refrescando ??= refrescar();
|
|
107
189
|
if (cache.at === 0) await refrescando;
|
|
108
190
|
}
|
|
109
|
-
return cache;
|
|
191
|
+
return cache.patrones;
|
|
110
192
|
}
|
|
111
193
|
return {
|
|
194
|
+
/** Los prefijos de API efectivos, normalizados (para los avisos). */
|
|
195
|
+
prefijosApi,
|
|
112
196
|
/** Rutas de API con precio que este sitio no puede cobrar por falta de secreto. */
|
|
113
197
|
async sinSecreto() {
|
|
114
198
|
if (opciones.secreto) return [];
|
|
115
|
-
return (await
|
|
199
|
+
return (await patrones()).filter((p) => patronPermitido(p.path, prefijosApi));
|
|
200
|
+
},
|
|
201
|
+
/** Rutas de API con precio que no se reenvían por caer fuera de `prefijosApi` o en un path reservado. */
|
|
202
|
+
async fueraDePrefijos() {
|
|
203
|
+
return (await patrones()).filter((p) => patronReenviable(p.path) && !patronPermitido(p.path, prefijosApi));
|
|
116
204
|
},
|
|
117
205
|
/** Fuerza la carga del manifiesto (útil al arrancar o en tests). */
|
|
118
206
|
cargar: () => refrescando ??= refrescar(),
|
|
207
|
+
/**
|
|
208
|
+
* Si la request es un bot leyendo una página del sitio, le avisa al
|
|
209
|
+
* gateway y devuelve la promesa del aviso (para `waitUntil`); si no, null.
|
|
210
|
+
* El aviso nunca lanza ni demora la página: 3 s de tope y se descarta la
|
|
211
|
+
* respuesta.
|
|
212
|
+
*/
|
|
213
|
+
reportarLectura(input) {
|
|
214
|
+
const lectura = lecturaDeBot(input);
|
|
215
|
+
if (!lectura) return null;
|
|
216
|
+
return fetchImpl(`${base}/kit/visita`, {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: { "content-type": "application/json" },
|
|
219
|
+
body: JSON.stringify(lectura),
|
|
220
|
+
signal: AbortSignal.timeout(3e3)
|
|
221
|
+
}).then((res) => res.body?.cancel()).catch(() => void 0);
|
|
222
|
+
},
|
|
119
223
|
/**
|
|
120
224
|
* Decide si la request va al gateway. `method` y `pathname` de la request,
|
|
121
225
|
* `search` con el `?` incluido, y los headers para cortar bucles.
|
|
@@ -124,14 +228,18 @@ function crearProxyRuntime(opciones) {
|
|
|
124
228
|
const secreto = opciones.secreto || null;
|
|
125
229
|
const prueba = input.headers.get(PEAJE_ORIGIN_HEADER);
|
|
126
230
|
if (secreto && prueba && iguales(prueba, secreto)) return null;
|
|
127
|
-
const { rutas, prefijos, patrones } = await listas();
|
|
128
231
|
const path = input.pathname;
|
|
129
232
|
const canon = pathCanonico(path).toLowerCase();
|
|
130
233
|
const esFetchDePeaje = input.headers.get(PEAJE_FETCH_HEADER) !== null;
|
|
131
234
|
const fija = (rutas.includes(path) || rutas.some((r) => r.toLowerCase() === canon)) && !(canon === "/llms.txt" && esFetchDePeaje);
|
|
132
235
|
const prefijo = prefijos.some((p) => path.startsWith(p) || canon.startsWith(p.toLowerCase()));
|
|
133
|
-
|
|
134
|
-
|
|
236
|
+
let patron = false;
|
|
237
|
+
if (!fija && !prefijo && secreto !== null && !esReservado(path) && prefijosApi.some((pre) => canon.startsWith(pre))) {
|
|
238
|
+
const method = input.method.toUpperCase();
|
|
239
|
+
patron = (await patrones()).some(
|
|
240
|
+
(p) => p.method.toUpperCase() === method && patronPermitido(p.path, prefijosApi) && coincidePatron(p.path, path)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
135
243
|
if (!fija && !prefijo && !patron) return null;
|
|
136
244
|
return { destino: `${base}${path}${input.search ?? ""}` };
|
|
137
245
|
}
|
|
@@ -276,6 +384,24 @@ function baseDe({ slug, gateway }) {
|
|
|
276
384
|
}
|
|
277
385
|
var SALTO = ["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "content-length"];
|
|
278
386
|
var avisado = false;
|
|
387
|
+
function avisar(runtime, conSecreto) {
|
|
388
|
+
if (!conSecreto) {
|
|
389
|
+
void runtime.sinSecreto().then((faltan) => {
|
|
390
|
+
if (faltan.length > 0) {
|
|
391
|
+
console.warn(
|
|
392
|
+
`[peaje] ${faltan.length} priced API route(s) are not charged on this domain because PEAJE_ORIGIN_SECRET is not set: ${faltan.map((p) => `${p.method} ${p.path}`).join(", ")}. Copy it from the Kit page of your Peaje dashboard into your hosting environment variables.`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
void runtime.fueraDePrefijos().then((fuera) => {
|
|
398
|
+
if (fuera.length > 0) {
|
|
399
|
+
console.warn(
|
|
400
|
+
`[peaje] ${fuera.length} priced API route(s) are outside apiPrefixes (${runtime.prefijosApi.join(", ") || "none"}) or on a reserved path, so they are charged only at the gateway URL, not on this domain: ${fuera.map((p) => `${p.method} ${p.path}`).join(", ")}. A route under a prefix of its own (/weather/:city) is charged here once that prefix is in the apiPrefixes option; a single path at the root or a reserved path never is.`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
279
405
|
function _reiniciarAviso() {
|
|
280
406
|
avisado = false;
|
|
281
407
|
}
|
|
@@ -284,24 +410,14 @@ function crearPeaje(opciones) {
|
|
|
284
410
|
const base = baseDe(opciones);
|
|
285
411
|
const secreto = opciones.secret ?? env("PEAJE_ORIGIN_SECRET") ?? null;
|
|
286
412
|
const fetchImpl = opciones.fetch ?? ((...a) => fetch(...a));
|
|
287
|
-
const runtime = crearProxyRuntime({
|
|
288
|
-
|
|
289
|
-
secreto,
|
|
290
|
-
fetchImpl,
|
|
291
|
-
respaldo: { rutas: RUTAS_PROXY.map((x) => x.path), prefijos: RUTAS_DINAMICAS.map((x) => x.prefijo), patrones: [] }
|
|
292
|
-
});
|
|
413
|
+
const runtime = crearProxyRuntime({ base, secreto, fetchImpl, prefijosApi: opciones.apiPrefixes ?? PREFIJOS_API_POR_DEFECTO });
|
|
414
|
+
const reportar = opciones.reportBotVisits !== false;
|
|
293
415
|
return {
|
|
294
416
|
base,
|
|
295
417
|
async decidir({ method, url, headers }) {
|
|
296
|
-
if (!
|
|
418
|
+
if (!avisado) {
|
|
297
419
|
avisado = true;
|
|
298
|
-
|
|
299
|
-
if (faltan.length > 0) {
|
|
300
|
-
console.warn(
|
|
301
|
-
`[peaje] ${faltan.length} priced API route(s) are not charged on this domain because PEAJE_ORIGIN_SECRET is not set: ${faltan.map((p) => `${p.method} ${p.path}`).join(", ")}. Copy it from the Kit page of your Peaje dashboard into your hosting environment variables.`
|
|
302
|
-
);
|
|
303
|
-
}
|
|
304
|
-
});
|
|
420
|
+
avisar(runtime, Boolean(secreto));
|
|
305
421
|
}
|
|
306
422
|
const d = await runtime.decidir({ method, pathname: url.pathname, search: url.search, headers });
|
|
307
423
|
return d?.destino ?? null;
|
|
@@ -309,6 +425,7 @@ function crearPeaje(opciones) {
|
|
|
309
425
|
async reenviar(destino, { method, url, headers, body }) {
|
|
310
426
|
const salida = new Headers(headers);
|
|
311
427
|
for (const h of SALTO) salida.delete(h);
|
|
428
|
+
for (const h of HEADERS_NO_REENVIADOS) salida.delete(h);
|
|
312
429
|
const primero = (h) => headers.get(h)?.split(",")[0]?.trim() || null;
|
|
313
430
|
const host = primero("x-forwarded-host") ?? url.host;
|
|
314
431
|
salida.set("x-forwarded-host", host);
|
|
@@ -322,16 +439,26 @@ function crearPeaje(opciones) {
|
|
|
322
439
|
vuelta.delete("transfer-encoding");
|
|
323
440
|
vuelta.delete("connection");
|
|
324
441
|
return new Response(method === "HEAD" ? null : res.body, { status: res.status, statusText: res.statusText, headers: vuelta });
|
|
442
|
+
},
|
|
443
|
+
reportarLectura({ method, url, headers }) {
|
|
444
|
+
return reportar ? runtime.reportarLectura({ method, pathname: url.pathname, headers }) : null;
|
|
325
445
|
}
|
|
326
446
|
};
|
|
327
447
|
}
|
|
328
448
|
function peajeFetch(opciones) {
|
|
329
449
|
const peaje = crearPeaje(opciones);
|
|
330
|
-
return async (request) => {
|
|
450
|
+
return async (request, waitUntil) => {
|
|
331
451
|
const url = new URL(request.url);
|
|
332
452
|
const entrada = { method: request.method.toUpperCase(), url, headers: request.headers };
|
|
333
453
|
const destino = await peaje.decidir(entrada);
|
|
334
|
-
if (!destino)
|
|
454
|
+
if (!destino) {
|
|
455
|
+
const lectura = peaje.reportarLectura(entrada);
|
|
456
|
+
if (lectura) {
|
|
457
|
+
if (waitUntil) waitUntil(lectura);
|
|
458
|
+
else void lectura;
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
335
462
|
const body = entrada.method === "GET" || entrada.method === "HEAD" ? null : await request.arrayBuffer();
|
|
336
463
|
return peaje.reenviar(destino, { ...entrada, body });
|
|
337
464
|
};
|
|
@@ -340,6 +467,7 @@ var src_default = peajeFetch;
|
|
|
340
467
|
|
|
341
468
|
export {
|
|
342
469
|
PEAJE_HOST_HEADER,
|
|
470
|
+
HEADERS_NO_REENVIADOS,
|
|
343
471
|
GATEWAY_POR_DEFECTO,
|
|
344
472
|
baseDe,
|
|
345
473
|
_reiniciarAviso,
|
package/dist/express.cjs
CHANGED
|
@@ -53,9 +53,54 @@ var RUTAS_DINAMICAS = [
|
|
|
53
53
|
{ path: "/r/:slug", prefijo: "/r/", nota: "links con precio: el 402 en tu dominio" },
|
|
54
54
|
{ path: "/checkout_sessions/:id", prefijo: "/checkout_sessions/", nota: "leer una sesi\xF3n de checkout ACP" }
|
|
55
55
|
];
|
|
56
|
+
var PREFIJOS_API_POR_DEFECTO = ["/api/", "/v1/"];
|
|
57
|
+
var PATHS_RESERVADOS = [
|
|
58
|
+
"/",
|
|
59
|
+
"/login",
|
|
60
|
+
"/logout",
|
|
61
|
+
"/signin",
|
|
62
|
+
"/signout",
|
|
63
|
+
"/signup",
|
|
64
|
+
"/sign-in",
|
|
65
|
+
"/sign-out",
|
|
66
|
+
"/sign-up",
|
|
67
|
+
"/register",
|
|
68
|
+
"/auth",
|
|
69
|
+
"/oauth",
|
|
70
|
+
"/session",
|
|
71
|
+
"/account",
|
|
72
|
+
"/admin",
|
|
73
|
+
"/dashboard",
|
|
74
|
+
"/_next",
|
|
75
|
+
"/_vercel",
|
|
76
|
+
"/api/auth",
|
|
77
|
+
"/static",
|
|
78
|
+
"/assets",
|
|
79
|
+
"/favicon.ico",
|
|
80
|
+
"/robots.txt",
|
|
81
|
+
"/sitemap.xml"
|
|
82
|
+
];
|
|
56
83
|
var PEAJE_FETCH_HEADER = "x-peaje-fetch";
|
|
57
84
|
var PEAJE_HOST_HEADER = "x-peaje-forwarded-host";
|
|
58
85
|
|
|
86
|
+
// ../shared/src/kit/bots.ts
|
|
87
|
+
var BOTS_DE_VISITA = [
|
|
88
|
+
{ patron: /OAI-SearchBot/i, tipo: "openai-searchbot" },
|
|
89
|
+
{ patron: /PerplexityBot/i, tipo: "perplexitybot" },
|
|
90
|
+
{ patron: /Googlebot/i, tipo: "googlebot" },
|
|
91
|
+
{ patron: /ClaudeBot|anthropic-ai/i, tipo: "claudebot" },
|
|
92
|
+
{ patron: /GPTBot/i, tipo: "gptbot" }
|
|
93
|
+
];
|
|
94
|
+
function tipoDeBot(ua) {
|
|
95
|
+
if (!ua) return null;
|
|
96
|
+
for (const b of BOTS_DE_VISITA) if (b.patron.test(ua)) return b.tipo;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
var PEAJE_VERIFICADOR_HEADER = "x-peaje-verificador";
|
|
100
|
+
var NO_ES_PAGINA = /\.(png|jpe?g|gif|webp|avif|svg|ico|css|m?js|map|woff2?|ttf|otf|eot|mp4|webm|mp3|wav)$/i;
|
|
101
|
+
var MAX_PATH_LECTURA = 500;
|
|
102
|
+
var MAX_UA_LECTURA = 300;
|
|
103
|
+
|
|
59
104
|
// ../shared/src/kit/proxy-runtime.ts
|
|
60
105
|
var PEAJE_ORIGIN_HEADER = "x-peaje-origin";
|
|
61
106
|
function iguales(a, b) {
|
|
@@ -64,6 +109,38 @@ function iguales(a, b) {
|
|
|
64
109
|
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
65
110
|
return d === 0;
|
|
66
111
|
}
|
|
112
|
+
var HEADERS_NO_REENVIADOS = ["cookie"];
|
|
113
|
+
function esReservado(path) {
|
|
114
|
+
const canon = pathCanonico(path).toLowerCase();
|
|
115
|
+
return PATHS_RESERVADOS.some((r) => r === "/" ? canon === "/" : canon === r || canon.startsWith(`${r}/`));
|
|
116
|
+
}
|
|
117
|
+
function prefijosApiValidos(prefijos = PREFIJOS_API_POR_DEFECTO) {
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const crudo of prefijos) {
|
|
120
|
+
const segs = segmentosCanonicos(String(crudo ?? "")).map((s) => s.toLowerCase());
|
|
121
|
+
if (segs.length === 0) continue;
|
|
122
|
+
const path = `/${segs.join("/")}`;
|
|
123
|
+
if (esReservado(path)) continue;
|
|
124
|
+
if (!out.includes(`${path}/`)) out.push(`${path}/`);
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
function patronPermitido(patron, prefijos) {
|
|
129
|
+
if (!patronReenviable(patron)) return false;
|
|
130
|
+
const segs = patron.split("/").filter(Boolean);
|
|
131
|
+
const literal = (s) => s !== "*" && !s.startsWith(":");
|
|
132
|
+
const bajoPrefijo = prefijos.some((pre) => {
|
|
133
|
+
const ps = pre.split("/").filter(Boolean);
|
|
134
|
+
return segs.length > ps.length && ps.every((s, i) => literal(segs[i]) && segs[i].toLowerCase() === s);
|
|
135
|
+
});
|
|
136
|
+
if (!bajoPrefijo) return false;
|
|
137
|
+
const literales = [];
|
|
138
|
+
for (const s of segs) {
|
|
139
|
+
if (!literal(s)) break;
|
|
140
|
+
literales.push(s);
|
|
141
|
+
}
|
|
142
|
+
return !esReservado(`/${literales.join("/")}`);
|
|
143
|
+
}
|
|
67
144
|
function coincidePatron(patron, pathname) {
|
|
68
145
|
const p = patron.split("/").filter(Boolean);
|
|
69
146
|
const parts = segmentosCanonicos(pathname);
|
|
@@ -100,50 +177,77 @@ function patronReenviable(patron) {
|
|
|
100
177
|
if (segs.length === 0) return false;
|
|
101
178
|
return !(segs.length === 1 && (segs[0] === "*" || segs[0].startsWith(":")));
|
|
102
179
|
}
|
|
180
|
+
function lecturaDeBot(input) {
|
|
181
|
+
const method = input.method.toUpperCase();
|
|
182
|
+
if (method !== "GET" && method !== "HEAD") return null;
|
|
183
|
+
const h = input.headers;
|
|
184
|
+
if (h.has(PEAJE_ORIGIN_HEADER) || h.has(PEAJE_FETCH_HEADER) || h.has(PEAJE_VERIFICADOR_HEADER)) return null;
|
|
185
|
+
const ua = h.get("user-agent");
|
|
186
|
+
if (!ua || !tipoDeBot(ua)) return null;
|
|
187
|
+
const path = input.pathname;
|
|
188
|
+
if (!path.startsWith("/") || path.startsWith("/_next/") || path.startsWith("/_vercel/") || NO_ES_PAGINA.test(path)) return null;
|
|
189
|
+
return { path: path.slice(0, MAX_PATH_LECTURA), method, userAgent: ua.slice(0, MAX_UA_LECTURA) };
|
|
190
|
+
}
|
|
103
191
|
function crearProxyRuntime(opciones) {
|
|
104
192
|
const base = opciones.base.replace(/\/+$/, "");
|
|
105
193
|
const ttl = opciones.ttlMs ?? 6e4;
|
|
106
194
|
const fetchImpl = opciones.fetchImpl ?? fetch;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
patrones: opciones.respaldo?.patrones ?? []
|
|
112
|
-
};
|
|
195
|
+
const rutas = [...opciones.fijas?.rutas ?? RUTAS_PROXY.map((r) => r.path)];
|
|
196
|
+
const prefijos = [...opciones.fijas?.prefijos ?? RUTAS_DINAMICAS.map((r) => r.prefijo)];
|
|
197
|
+
const prefijosApi = prefijosApiValidos(opciones.prefijosApi ?? PREFIJOS_API_POR_DEFECTO);
|
|
198
|
+
let cache = { at: 0, patrones: [] };
|
|
113
199
|
let refrescando = null;
|
|
114
200
|
async function refrescar() {
|
|
115
201
|
try {
|
|
116
202
|
const res = await fetchImpl(`${base}/kit/manifest.json`, { signal: AbortSignal.timeout(5e3) });
|
|
117
203
|
if (!res.ok) throw new Error(String(res.status));
|
|
118
204
|
const m = await res.json();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
rutas: Array.isArray(m.rutas) ? m.rutas : cache.rutas,
|
|
122
|
-
prefijos: Array.isArray(m.prefijos) ? m.prefijos : cache.prefijos,
|
|
123
|
-
patrones: Array.isArray(m.patrones) ? m.patrones : cache.patrones
|
|
124
|
-
};
|
|
205
|
+
const patrones2 = Array.isArray(m.patrones) ? m.patrones.filter((p) => typeof p?.method === "string" && typeof p?.path === "string") : cache.patrones;
|
|
206
|
+
cache = { at: Date.now(), patrones: patrones2 };
|
|
125
207
|
} catch {
|
|
126
208
|
cache = { ...cache, at: Date.now() };
|
|
127
209
|
} finally {
|
|
128
210
|
refrescando = null;
|
|
129
211
|
}
|
|
130
212
|
}
|
|
131
|
-
async function
|
|
213
|
+
async function patrones() {
|
|
132
214
|
const vencido = Date.now() - cache.at >= ttl;
|
|
133
215
|
if (vencido) {
|
|
134
216
|
refrescando ??= refrescar();
|
|
135
217
|
if (cache.at === 0) await refrescando;
|
|
136
218
|
}
|
|
137
|
-
return cache;
|
|
219
|
+
return cache.patrones;
|
|
138
220
|
}
|
|
139
221
|
return {
|
|
222
|
+
/** Los prefijos de API efectivos, normalizados (para los avisos). */
|
|
223
|
+
prefijosApi,
|
|
140
224
|
/** Rutas de API con precio que este sitio no puede cobrar por falta de secreto. */
|
|
141
225
|
async sinSecreto() {
|
|
142
226
|
if (opciones.secreto) return [];
|
|
143
|
-
return (await
|
|
227
|
+
return (await patrones()).filter((p) => patronPermitido(p.path, prefijosApi));
|
|
228
|
+
},
|
|
229
|
+
/** Rutas de API con precio que no se reenvían por caer fuera de `prefijosApi` o en un path reservado. */
|
|
230
|
+
async fueraDePrefijos() {
|
|
231
|
+
return (await patrones()).filter((p) => patronReenviable(p.path) && !patronPermitido(p.path, prefijosApi));
|
|
144
232
|
},
|
|
145
233
|
/** Fuerza la carga del manifiesto (útil al arrancar o en tests). */
|
|
146
234
|
cargar: () => refrescando ??= refrescar(),
|
|
235
|
+
/**
|
|
236
|
+
* Si la request es un bot leyendo una página del sitio, le avisa al
|
|
237
|
+
* gateway y devuelve la promesa del aviso (para `waitUntil`); si no, null.
|
|
238
|
+
* El aviso nunca lanza ni demora la página: 3 s de tope y se descarta la
|
|
239
|
+
* respuesta.
|
|
240
|
+
*/
|
|
241
|
+
reportarLectura(input) {
|
|
242
|
+
const lectura = lecturaDeBot(input);
|
|
243
|
+
if (!lectura) return null;
|
|
244
|
+
return fetchImpl(`${base}/kit/visita`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/json" },
|
|
247
|
+
body: JSON.stringify(lectura),
|
|
248
|
+
signal: AbortSignal.timeout(3e3)
|
|
249
|
+
}).then((res) => res.body?.cancel()).catch(() => void 0);
|
|
250
|
+
},
|
|
147
251
|
/**
|
|
148
252
|
* Decide si la request va al gateway. `method` y `pathname` de la request,
|
|
149
253
|
* `search` con el `?` incluido, y los headers para cortar bucles.
|
|
@@ -152,14 +256,18 @@ function crearProxyRuntime(opciones) {
|
|
|
152
256
|
const secreto = opciones.secreto || null;
|
|
153
257
|
const prueba = input.headers.get(PEAJE_ORIGIN_HEADER);
|
|
154
258
|
if (secreto && prueba && iguales(prueba, secreto)) return null;
|
|
155
|
-
const { rutas, prefijos, patrones } = await listas();
|
|
156
259
|
const path = input.pathname;
|
|
157
260
|
const canon = pathCanonico(path).toLowerCase();
|
|
158
261
|
const esFetchDePeaje = input.headers.get(PEAJE_FETCH_HEADER) !== null;
|
|
159
262
|
const fija = (rutas.includes(path) || rutas.some((r) => r.toLowerCase() === canon)) && !(canon === "/llms.txt" && esFetchDePeaje);
|
|
160
263
|
const prefijo = prefijos.some((p) => path.startsWith(p) || canon.startsWith(p.toLowerCase()));
|
|
161
|
-
|
|
162
|
-
|
|
264
|
+
let patron = false;
|
|
265
|
+
if (!fija && !prefijo && secreto !== null && !esReservado(path) && prefijosApi.some((pre) => canon.startsWith(pre))) {
|
|
266
|
+
const method = input.method.toUpperCase();
|
|
267
|
+
patron = (await patrones()).some(
|
|
268
|
+
(p) => p.method.toUpperCase() === method && patronPermitido(p.path, prefijosApi) && coincidePatron(p.path, path)
|
|
269
|
+
);
|
|
270
|
+
}
|
|
163
271
|
if (!fija && !prefijo && !patron) return null;
|
|
164
272
|
return { destino: `${base}${path}${input.search ?? ""}` };
|
|
165
273
|
}
|
|
@@ -304,29 +412,37 @@ function baseDe({ slug, gateway }) {
|
|
|
304
412
|
}
|
|
305
413
|
var SALTO = ["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "content-length"];
|
|
306
414
|
var avisado = false;
|
|
415
|
+
function avisar(runtime, conSecreto) {
|
|
416
|
+
if (!conSecreto) {
|
|
417
|
+
void runtime.sinSecreto().then((faltan) => {
|
|
418
|
+
if (faltan.length > 0) {
|
|
419
|
+
console.warn(
|
|
420
|
+
`[peaje] ${faltan.length} priced API route(s) are not charged on this domain because PEAJE_ORIGIN_SECRET is not set: ${faltan.map((p) => `${p.method} ${p.path}`).join(", ")}. Copy it from the Kit page of your Peaje dashboard into your hosting environment variables.`
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
void runtime.fueraDePrefijos().then((fuera) => {
|
|
426
|
+
if (fuera.length > 0) {
|
|
427
|
+
console.warn(
|
|
428
|
+
`[peaje] ${fuera.length} priced API route(s) are outside apiPrefixes (${runtime.prefijosApi.join(", ") || "none"}) or on a reserved path, so they are charged only at the gateway URL, not on this domain: ${fuera.map((p) => `${p.method} ${p.path}`).join(", ")}. A route under a prefix of its own (/weather/:city) is charged here once that prefix is in the apiPrefixes option; a single path at the root or a reserved path never is.`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
307
433
|
function crearPeaje(opciones) {
|
|
308
434
|
if (!opciones?.slug) throw new Error("@peaje/proxy: `slug` is required (the business slug from your Peaje dashboard).");
|
|
309
435
|
const base = baseDe(opciones);
|
|
310
436
|
const secreto = opciones.secret ?? env("PEAJE_ORIGIN_SECRET") ?? null;
|
|
311
437
|
const fetchImpl = opciones.fetch ?? ((...a) => fetch(...a));
|
|
312
|
-
const runtime = crearProxyRuntime({
|
|
313
|
-
|
|
314
|
-
secreto,
|
|
315
|
-
fetchImpl,
|
|
316
|
-
respaldo: { rutas: RUTAS_PROXY.map((x) => x.path), prefijos: RUTAS_DINAMICAS.map((x) => x.prefijo), patrones: [] }
|
|
317
|
-
});
|
|
438
|
+
const runtime = crearProxyRuntime({ base, secreto, fetchImpl, prefijosApi: opciones.apiPrefixes ?? PREFIJOS_API_POR_DEFECTO });
|
|
439
|
+
const reportar = opciones.reportBotVisits !== false;
|
|
318
440
|
return {
|
|
319
441
|
base,
|
|
320
442
|
async decidir({ method, url, headers }) {
|
|
321
|
-
if (!
|
|
443
|
+
if (!avisado) {
|
|
322
444
|
avisado = true;
|
|
323
|
-
|
|
324
|
-
if (faltan.length > 0) {
|
|
325
|
-
console.warn(
|
|
326
|
-
`[peaje] ${faltan.length} priced API route(s) are not charged on this domain because PEAJE_ORIGIN_SECRET is not set: ${faltan.map((p) => `${p.method} ${p.path}`).join(", ")}. Copy it from the Kit page of your Peaje dashboard into your hosting environment variables.`
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
});
|
|
445
|
+
avisar(runtime, Boolean(secreto));
|
|
330
446
|
}
|
|
331
447
|
const d = await runtime.decidir({ method, pathname: url.pathname, search: url.search, headers });
|
|
332
448
|
return d?.destino ?? null;
|
|
@@ -334,6 +450,7 @@ function crearPeaje(opciones) {
|
|
|
334
450
|
async reenviar(destino, { method, url, headers, body }) {
|
|
335
451
|
const salida = new Headers(headers);
|
|
336
452
|
for (const h of SALTO) salida.delete(h);
|
|
453
|
+
for (const h of HEADERS_NO_REENVIADOS) salida.delete(h);
|
|
337
454
|
const primero = (h) => headers.get(h)?.split(",")[0]?.trim() || null;
|
|
338
455
|
const host = primero("x-forwarded-host") ?? url.host;
|
|
339
456
|
salida.set("x-forwarded-host", host);
|
|
@@ -347,6 +464,9 @@ function crearPeaje(opciones) {
|
|
|
347
464
|
vuelta.delete("transfer-encoding");
|
|
348
465
|
vuelta.delete("connection");
|
|
349
466
|
return new Response(method === "HEAD" ? null : res.body, { status: res.status, statusText: res.statusText, headers: vuelta });
|
|
467
|
+
},
|
|
468
|
+
reportarLectura({ method, url, headers }) {
|
|
469
|
+
return reportar ? runtime.reportarLectura({ method, pathname: url.pathname, headers }) : null;
|
|
350
470
|
}
|
|
351
471
|
};
|
|
352
472
|
}
|
|
@@ -406,7 +526,10 @@ function peajeExpress(opciones) {
|
|
|
406
526
|
const r = req;
|
|
407
527
|
const entrada = { method: (r.method ?? "GET").toUpperCase(), url: urlDe(r), headers: headersDe(r) };
|
|
408
528
|
peaje.decidir(entrada).then(async (destino) => {
|
|
409
|
-
if (!destino)
|
|
529
|
+
if (!destino) {
|
|
530
|
+
void peaje.reportarLectura(entrada);
|
|
531
|
+
return next();
|
|
532
|
+
}
|
|
410
533
|
const body = entrada.method === "GET" || entrada.method === "HEAD" ? null : await bodyDe(r);
|
|
411
534
|
await enviar(res, await peaje.reenviar(destino, { ...entrada, body }));
|
|
412
535
|
}).catch((e) => {
|