@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 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 query, and carries `x-forwarded-host` and `x-forwarded-proto` so the 402 challenge names your domain (strict x402 clients abort when it does not). If the gateway is unreachable, the catalog paths are still forwarded from a built-in list; priced API routes resume when the manifest loads.
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
- let cache = {
80
- at: 0,
81
- rutas: opciones.respaldo?.rutas ?? [],
82
- prefijos: opciones.respaldo?.prefijos ?? [],
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
- cache = {
92
- at: Date.now(),
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 listas() {
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 listas()).patrones.filter((p) => patronReenviable(p.path));
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
- const method = input.method.toUpperCase();
134
- const patron = secreto !== null && patrones.some((p) => p.method.toUpperCase() === method && patronReenviable(p.path) && coincidePatron(p.path, path));
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
- base,
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 (!secreto && !avisado) {
418
+ if (!avisado) {
297
419
  avisado = true;
298
- void runtime.sinSecreto().then((faltan) => {
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) return null;
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
- let cache = {
108
- at: 0,
109
- rutas: opciones.respaldo?.rutas ?? [],
110
- prefijos: opciones.respaldo?.prefijos ?? [],
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
- cache = {
120
- at: Date.now(),
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 listas() {
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 listas()).patrones.filter((p) => patronReenviable(p.path));
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
- const method = input.method.toUpperCase();
162
- const patron = secreto !== null && patrones.some((p) => p.method.toUpperCase() === method && patronReenviable(p.path) && coincidePatron(p.path, path));
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
- base,
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 (!secreto && !avisado) {
443
+ if (!avisado) {
322
444
  avisado = true;
323
- void runtime.sinSecreto().then((faltan) => {
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) return next();
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) => {