@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/dist/vercel.cjs CHANGED
@@ -52,9 +52,54 @@ var RUTAS_DINAMICAS = [
52
52
  { path: "/r/:slug", prefijo: "/r/", nota: "links con precio: el 402 en tu dominio" },
53
53
  { path: "/checkout_sessions/:id", prefijo: "/checkout_sessions/", nota: "leer una sesi\xF3n de checkout ACP" }
54
54
  ];
55
+ var PREFIJOS_API_POR_DEFECTO = ["/api/", "/v1/"];
56
+ var PATHS_RESERVADOS = [
57
+ "/",
58
+ "/login",
59
+ "/logout",
60
+ "/signin",
61
+ "/signout",
62
+ "/signup",
63
+ "/sign-in",
64
+ "/sign-out",
65
+ "/sign-up",
66
+ "/register",
67
+ "/auth",
68
+ "/oauth",
69
+ "/session",
70
+ "/account",
71
+ "/admin",
72
+ "/dashboard",
73
+ "/_next",
74
+ "/_vercel",
75
+ "/api/auth",
76
+ "/static",
77
+ "/assets",
78
+ "/favicon.ico",
79
+ "/robots.txt",
80
+ "/sitemap.xml"
81
+ ];
55
82
  var PEAJE_FETCH_HEADER = "x-peaje-fetch";
56
83
  var PEAJE_HOST_HEADER = "x-peaje-forwarded-host";
57
84
 
85
+ // ../shared/src/kit/bots.ts
86
+ var BOTS_DE_VISITA = [
87
+ { patron: /OAI-SearchBot/i, tipo: "openai-searchbot" },
88
+ { patron: /PerplexityBot/i, tipo: "perplexitybot" },
89
+ { patron: /Googlebot/i, tipo: "googlebot" },
90
+ { patron: /ClaudeBot|anthropic-ai/i, tipo: "claudebot" },
91
+ { patron: /GPTBot/i, tipo: "gptbot" }
92
+ ];
93
+ function tipoDeBot(ua) {
94
+ if (!ua) return null;
95
+ for (const b of BOTS_DE_VISITA) if (b.patron.test(ua)) return b.tipo;
96
+ return null;
97
+ }
98
+ var PEAJE_VERIFICADOR_HEADER = "x-peaje-verificador";
99
+ 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;
100
+ var MAX_PATH_LECTURA = 500;
101
+ var MAX_UA_LECTURA = 300;
102
+
58
103
  // ../shared/src/kit/proxy-runtime.ts
59
104
  var PEAJE_ORIGIN_HEADER = "x-peaje-origin";
60
105
  function iguales(a, b) {
@@ -63,6 +108,38 @@ function iguales(a, b) {
63
108
  for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
64
109
  return d === 0;
65
110
  }
111
+ var HEADERS_NO_REENVIADOS = ["cookie"];
112
+ function esReservado(path) {
113
+ const canon = pathCanonico(path).toLowerCase();
114
+ return PATHS_RESERVADOS.some((r) => r === "/" ? canon === "/" : canon === r || canon.startsWith(`${r}/`));
115
+ }
116
+ function prefijosApiValidos(prefijos = PREFIJOS_API_POR_DEFECTO) {
117
+ const out = [];
118
+ for (const crudo of prefijos) {
119
+ const segs = segmentosCanonicos(String(crudo ?? "")).map((s) => s.toLowerCase());
120
+ if (segs.length === 0) continue;
121
+ const path = `/${segs.join("/")}`;
122
+ if (esReservado(path)) continue;
123
+ if (!out.includes(`${path}/`)) out.push(`${path}/`);
124
+ }
125
+ return out;
126
+ }
127
+ function patronPermitido(patron, prefijos) {
128
+ if (!patronReenviable(patron)) return false;
129
+ const segs = patron.split("/").filter(Boolean);
130
+ const literal = (s) => s !== "*" && !s.startsWith(":");
131
+ const bajoPrefijo = prefijos.some((pre) => {
132
+ const ps = pre.split("/").filter(Boolean);
133
+ return segs.length > ps.length && ps.every((s, i) => literal(segs[i]) && segs[i].toLowerCase() === s);
134
+ });
135
+ if (!bajoPrefijo) return false;
136
+ const literales = [];
137
+ for (const s of segs) {
138
+ if (!literal(s)) break;
139
+ literales.push(s);
140
+ }
141
+ return !esReservado(`/${literales.join("/")}`);
142
+ }
66
143
  function coincidePatron(patron, pathname) {
67
144
  const p = patron.split("/").filter(Boolean);
68
145
  const parts = segmentosCanonicos(pathname);
@@ -99,50 +176,77 @@ function patronReenviable(patron) {
99
176
  if (segs.length === 0) return false;
100
177
  return !(segs.length === 1 && (segs[0] === "*" || segs[0].startsWith(":")));
101
178
  }
179
+ function lecturaDeBot(input) {
180
+ const method = input.method.toUpperCase();
181
+ if (method !== "GET" && method !== "HEAD") return null;
182
+ const h = input.headers;
183
+ if (h.has(PEAJE_ORIGIN_HEADER) || h.has(PEAJE_FETCH_HEADER) || h.has(PEAJE_VERIFICADOR_HEADER)) return null;
184
+ const ua = h.get("user-agent");
185
+ if (!ua || !tipoDeBot(ua)) return null;
186
+ const path = input.pathname;
187
+ if (!path.startsWith("/") || path.startsWith("/_next/") || path.startsWith("/_vercel/") || NO_ES_PAGINA.test(path)) return null;
188
+ return { path: path.slice(0, MAX_PATH_LECTURA), method, userAgent: ua.slice(0, MAX_UA_LECTURA) };
189
+ }
102
190
  function crearProxyRuntime(opciones) {
103
191
  const base = opciones.base.replace(/\/+$/, "");
104
192
  const ttl = opciones.ttlMs ?? 6e4;
105
193
  const fetchImpl = opciones.fetchImpl ?? fetch;
106
- let cache = {
107
- at: 0,
108
- rutas: opciones.respaldo?.rutas ?? [],
109
- prefijos: opciones.respaldo?.prefijos ?? [],
110
- patrones: opciones.respaldo?.patrones ?? []
111
- };
194
+ const rutas = [...opciones.fijas?.rutas ?? RUTAS_PROXY.map((r) => r.path)];
195
+ const prefijos = [...opciones.fijas?.prefijos ?? RUTAS_DINAMICAS.map((r) => r.prefijo)];
196
+ const prefijosApi = prefijosApiValidos(opciones.prefijosApi ?? PREFIJOS_API_POR_DEFECTO);
197
+ let cache = { at: 0, patrones: [] };
112
198
  let refrescando = null;
113
199
  async function refrescar() {
114
200
  try {
115
201
  const res = await fetchImpl(`${base}/kit/manifest.json`, { signal: AbortSignal.timeout(5e3) });
116
202
  if (!res.ok) throw new Error(String(res.status));
117
203
  const m = await res.json();
118
- cache = {
119
- at: Date.now(),
120
- rutas: Array.isArray(m.rutas) ? m.rutas : cache.rutas,
121
- prefijos: Array.isArray(m.prefijos) ? m.prefijos : cache.prefijos,
122
- patrones: Array.isArray(m.patrones) ? m.patrones : cache.patrones
123
- };
204
+ const patrones2 = Array.isArray(m.patrones) ? m.patrones.filter((p) => typeof p?.method === "string" && typeof p?.path === "string") : cache.patrones;
205
+ cache = { at: Date.now(), patrones: patrones2 };
124
206
  } catch {
125
207
  cache = { ...cache, at: Date.now() };
126
208
  } finally {
127
209
  refrescando = null;
128
210
  }
129
211
  }
130
- async function listas() {
212
+ async function patrones() {
131
213
  const vencido = Date.now() - cache.at >= ttl;
132
214
  if (vencido) {
133
215
  refrescando ??= refrescar();
134
216
  if (cache.at === 0) await refrescando;
135
217
  }
136
- return cache;
218
+ return cache.patrones;
137
219
  }
138
220
  return {
221
+ /** Los prefijos de API efectivos, normalizados (para los avisos). */
222
+ prefijosApi,
139
223
  /** Rutas de API con precio que este sitio no puede cobrar por falta de secreto. */
140
224
  async sinSecreto() {
141
225
  if (opciones.secreto) return [];
142
- return (await listas()).patrones.filter((p) => patronReenviable(p.path));
226
+ return (await patrones()).filter((p) => patronPermitido(p.path, prefijosApi));
227
+ },
228
+ /** Rutas de API con precio que no se reenvían por caer fuera de `prefijosApi` o en un path reservado. */
229
+ async fueraDePrefijos() {
230
+ return (await patrones()).filter((p) => patronReenviable(p.path) && !patronPermitido(p.path, prefijosApi));
143
231
  },
144
232
  /** Fuerza la carga del manifiesto (útil al arrancar o en tests). */
145
233
  cargar: () => refrescando ??= refrescar(),
234
+ /**
235
+ * Si la request es un bot leyendo una página del sitio, le avisa al
236
+ * gateway y devuelve la promesa del aviso (para `waitUntil`); si no, null.
237
+ * El aviso nunca lanza ni demora la página: 3 s de tope y se descarta la
238
+ * respuesta.
239
+ */
240
+ reportarLectura(input) {
241
+ const lectura = lecturaDeBot(input);
242
+ if (!lectura) return null;
243
+ return fetchImpl(`${base}/kit/visita`, {
244
+ method: "POST",
245
+ headers: { "content-type": "application/json" },
246
+ body: JSON.stringify(lectura),
247
+ signal: AbortSignal.timeout(3e3)
248
+ }).then((res) => res.body?.cancel()).catch(() => void 0);
249
+ },
146
250
  /**
147
251
  * Decide si la request va al gateway. `method` y `pathname` de la request,
148
252
  * `search` con el `?` incluido, y los headers para cortar bucles.
@@ -151,14 +255,18 @@ function crearProxyRuntime(opciones) {
151
255
  const secreto = opciones.secreto || null;
152
256
  const prueba = input.headers.get(PEAJE_ORIGIN_HEADER);
153
257
  if (secreto && prueba && iguales(prueba, secreto)) return null;
154
- const { rutas, prefijos, patrones } = await listas();
155
258
  const path = input.pathname;
156
259
  const canon = pathCanonico(path).toLowerCase();
157
260
  const esFetchDePeaje = input.headers.get(PEAJE_FETCH_HEADER) !== null;
158
261
  const fija = (rutas.includes(path) || rutas.some((r) => r.toLowerCase() === canon)) && !(canon === "/llms.txt" && esFetchDePeaje);
159
262
  const prefijo = prefijos.some((p) => path.startsWith(p) || canon.startsWith(p.toLowerCase()));
160
- const method = input.method.toUpperCase();
161
- const patron = secreto !== null && patrones.some((p) => p.method.toUpperCase() === method && patronReenviable(p.path) && coincidePatron(p.path, path));
263
+ let patron = false;
264
+ if (!fija && !prefijo && secreto !== null && !esReservado(path) && prefijosApi.some((pre) => canon.startsWith(pre))) {
265
+ const method = input.method.toUpperCase();
266
+ patron = (await patrones()).some(
267
+ (p) => p.method.toUpperCase() === method && patronPermitido(p.path, prefijosApi) && coincidePatron(p.path, path)
268
+ );
269
+ }
162
270
  if (!fija && !prefijo && !patron) return null;
163
271
  return { destino: `${base}${path}${input.search ?? ""}` };
164
272
  }
@@ -303,29 +411,37 @@ function baseDe({ slug, gateway }) {
303
411
  }
304
412
  var SALTO = ["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "content-length"];
305
413
  var avisado = false;
414
+ function avisar(runtime, conSecreto) {
415
+ if (!conSecreto) {
416
+ void runtime.sinSecreto().then((faltan) => {
417
+ if (faltan.length > 0) {
418
+ console.warn(
419
+ `[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.`
420
+ );
421
+ }
422
+ });
423
+ }
424
+ void runtime.fueraDePrefijos().then((fuera) => {
425
+ if (fuera.length > 0) {
426
+ console.warn(
427
+ `[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.`
428
+ );
429
+ }
430
+ });
431
+ }
306
432
  function crearPeaje(opciones) {
307
433
  if (!opciones?.slug) throw new Error("@peaje/proxy: `slug` is required (the business slug from your Peaje dashboard).");
308
434
  const base = baseDe(opciones);
309
435
  const secreto = opciones.secret ?? env("PEAJE_ORIGIN_SECRET") ?? null;
310
436
  const fetchImpl = opciones.fetch ?? ((...a) => fetch(...a));
311
- const runtime = crearProxyRuntime({
312
- base,
313
- secreto,
314
- fetchImpl,
315
- respaldo: { rutas: RUTAS_PROXY.map((x) => x.path), prefijos: RUTAS_DINAMICAS.map((x) => x.prefijo), patrones: [] }
316
- });
437
+ const runtime = crearProxyRuntime({ base, secreto, fetchImpl, prefijosApi: opciones.apiPrefixes ?? PREFIJOS_API_POR_DEFECTO });
438
+ const reportar = opciones.reportBotVisits !== false;
317
439
  return {
318
440
  base,
319
441
  async decidir({ method, url, headers }) {
320
- if (!secreto && !avisado) {
442
+ if (!avisado) {
321
443
  avisado = true;
322
- void runtime.sinSecreto().then((faltan) => {
323
- if (faltan.length > 0) {
324
- console.warn(
325
- `[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.`
326
- );
327
- }
328
- });
444
+ avisar(runtime, Boolean(secreto));
329
445
  }
330
446
  const d = await runtime.decidir({ method, pathname: url.pathname, search: url.search, headers });
331
447
  return d?.destino ?? null;
@@ -333,6 +449,7 @@ function crearPeaje(opciones) {
333
449
  async reenviar(destino, { method, url, headers, body }) {
334
450
  const salida = new Headers(headers);
335
451
  for (const h of SALTO) salida.delete(h);
452
+ for (const h of HEADERS_NO_REENVIADOS) salida.delete(h);
336
453
  const primero = (h) => headers.get(h)?.split(",")[0]?.trim() || null;
337
454
  const host = primero("x-forwarded-host") ?? url.host;
338
455
  salida.set("x-forwarded-host", host);
@@ -346,6 +463,9 @@ function crearPeaje(opciones) {
346
463
  vuelta.delete("transfer-encoding");
347
464
  vuelta.delete("connection");
348
465
  return new Response(method === "HEAD" ? null : res.body, { status: res.status, statusText: res.statusText, headers: vuelta });
466
+ },
467
+ reportarLectura({ method, url, headers }) {
468
+ return reportar ? runtime.reportarLectura({ method, pathname: url.pathname, headers }) : null;
349
469
  }
350
470
  };
351
471
  }
@@ -371,11 +491,17 @@ function peajeVercel(opciones, siguiente) {
371
491
  const destino = await peaje.decidir(entrada);
372
492
  if (destino) {
373
493
  const headers = new Headers(request.headers);
494
+ for (const h of HEADERS_NO_REENVIADOS) headers.delete(h);
374
495
  headers.set("x-forwarded-host", url.host);
375
496
  headers.set(PEAJE_HOST_HEADER, url.host);
376
497
  headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
377
498
  return respuestaMiddleware({ "x-middleware-rewrite": destino }, headers);
378
499
  }
500
+ const lectura = peaje.reportarLectura(entrada);
501
+ if (lectura) {
502
+ if (context?.waitUntil) context.waitUntil(lectura);
503
+ else void lectura;
504
+ }
379
505
  const propia = siguiente ? await siguiente(request, context) : void 0;
380
506
  return propia ?? respuestaMiddleware({ "x-middleware-next": "1" });
381
507
  };
package/dist/vercel.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import {
2
+ HEADERS_NO_REENVIADOS,
2
3
  PEAJE_HOST_HEADER,
3
4
  crearPeaje
4
- } from "./chunk-IU5YMR4J.js";
5
+ } from "./chunk-SWB3HJVY.js";
5
6
 
6
7
  // src/vercel.ts
7
8
  function respuestaMiddleware(control, requestHeaders) {
@@ -24,11 +25,17 @@ function peajeVercel(opciones, siguiente) {
24
25
  const destino = await peaje.decidir(entrada);
25
26
  if (destino) {
26
27
  const headers = new Headers(request.headers);
28
+ for (const h of HEADERS_NO_REENVIADOS) headers.delete(h);
27
29
  headers.set("x-forwarded-host", url.host);
28
30
  headers.set(PEAJE_HOST_HEADER, url.host);
29
31
  headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
30
32
  return respuestaMiddleware({ "x-middleware-rewrite": destino }, headers);
31
33
  }
34
+ const lectura = peaje.reportarLectura(entrada);
35
+ if (lectura) {
36
+ if (context?.waitUntil) context.waitUntil(lectura);
37
+ else void lectura;
38
+ }
32
39
  const propia = siguiente ? await siguiente(request, context) : void 0;
33
40
  return propia ?? respuestaMiddleware({ "x-middleware-next": "1" });
34
41
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaje/proxy",
3
- "version": "0.1.2",
3
+ "version": "0.2.1",
4
4
  "description": "Peaje for any server: answer HTTP 402 (MPP, x402) for your priced API routes on your own domain. Fetch handler plus Express, Hono and Vercel Routing Middleware adapters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,10 +8,13 @@
8
8
  "url": "git+https://github.com/sandragcarrillo/peaje.git",
9
9
  "directory": "packages/peaje-proxy"
10
10
  },
11
+ "homepage": "https://github.com/sandragcarrillo/peaje/tree/main/packages/peaje-proxy#readme",
12
+ "bugs": "https://github.com/sandragcarrillo/peaje/issues",
11
13
  "type": "module",
12
14
  "sideEffects": false,
13
15
  "files": [
14
- "dist"
16
+ "dist",
17
+ "LICENSE"
15
18
  ],
16
19
  "main": "./dist/index.cjs",
17
20
  "module": "./dist/index.js",