@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/index.cjs CHANGED
@@ -56,9 +56,54 @@ var RUTAS_DINAMICAS = [
56
56
  { path: "/r/:slug", prefijo: "/r/", nota: "links con precio: el 402 en tu dominio" },
57
57
  { path: "/checkout_sessions/:id", prefijo: "/checkout_sessions/", nota: "leer una sesi\xF3n de checkout ACP" }
58
58
  ];
59
+ var PREFIJOS_API_POR_DEFECTO = ["/api/", "/v1/"];
60
+ var PATHS_RESERVADOS = [
61
+ "/",
62
+ "/login",
63
+ "/logout",
64
+ "/signin",
65
+ "/signout",
66
+ "/signup",
67
+ "/sign-in",
68
+ "/sign-out",
69
+ "/sign-up",
70
+ "/register",
71
+ "/auth",
72
+ "/oauth",
73
+ "/session",
74
+ "/account",
75
+ "/admin",
76
+ "/dashboard",
77
+ "/_next",
78
+ "/_vercel",
79
+ "/api/auth",
80
+ "/static",
81
+ "/assets",
82
+ "/favicon.ico",
83
+ "/robots.txt",
84
+ "/sitemap.xml"
85
+ ];
59
86
  var PEAJE_FETCH_HEADER = "x-peaje-fetch";
60
87
  var PEAJE_HOST_HEADER = "x-peaje-forwarded-host";
61
88
 
89
+ // ../shared/src/kit/bots.ts
90
+ var BOTS_DE_VISITA = [
91
+ { patron: /OAI-SearchBot/i, tipo: "openai-searchbot" },
92
+ { patron: /PerplexityBot/i, tipo: "perplexitybot" },
93
+ { patron: /Googlebot/i, tipo: "googlebot" },
94
+ { patron: /ClaudeBot|anthropic-ai/i, tipo: "claudebot" },
95
+ { patron: /GPTBot/i, tipo: "gptbot" }
96
+ ];
97
+ function tipoDeBot(ua) {
98
+ if (!ua) return null;
99
+ for (const b of BOTS_DE_VISITA) if (b.patron.test(ua)) return b.tipo;
100
+ return null;
101
+ }
102
+ var PEAJE_VERIFICADOR_HEADER = "x-peaje-verificador";
103
+ 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;
104
+ var MAX_PATH_LECTURA = 500;
105
+ var MAX_UA_LECTURA = 300;
106
+
62
107
  // ../shared/src/kit/proxy-runtime.ts
63
108
  var PEAJE_ORIGIN_HEADER = "x-peaje-origin";
64
109
  function iguales(a, b) {
@@ -67,6 +112,38 @@ function iguales(a, b) {
67
112
  for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
68
113
  return d === 0;
69
114
  }
115
+ var HEADERS_NO_REENVIADOS = ["cookie"];
116
+ function esReservado(path) {
117
+ const canon = pathCanonico(path).toLowerCase();
118
+ return PATHS_RESERVADOS.some((r) => r === "/" ? canon === "/" : canon === r || canon.startsWith(`${r}/`));
119
+ }
120
+ function prefijosApiValidos(prefijos = PREFIJOS_API_POR_DEFECTO) {
121
+ const out = [];
122
+ for (const crudo of prefijos) {
123
+ const segs = segmentosCanonicos(String(crudo ?? "")).map((s) => s.toLowerCase());
124
+ if (segs.length === 0) continue;
125
+ const path = `/${segs.join("/")}`;
126
+ if (esReservado(path)) continue;
127
+ if (!out.includes(`${path}/`)) out.push(`${path}/`);
128
+ }
129
+ return out;
130
+ }
131
+ function patronPermitido(patron, prefijos) {
132
+ if (!patronReenviable(patron)) return false;
133
+ const segs = patron.split("/").filter(Boolean);
134
+ const literal = (s) => s !== "*" && !s.startsWith(":");
135
+ const bajoPrefijo = prefijos.some((pre) => {
136
+ const ps = pre.split("/").filter(Boolean);
137
+ return segs.length > ps.length && ps.every((s, i) => literal(segs[i]) && segs[i].toLowerCase() === s);
138
+ });
139
+ if (!bajoPrefijo) return false;
140
+ const literales = [];
141
+ for (const s of segs) {
142
+ if (!literal(s)) break;
143
+ literales.push(s);
144
+ }
145
+ return !esReservado(`/${literales.join("/")}`);
146
+ }
70
147
  function coincidePatron(patron, pathname) {
71
148
  const p = patron.split("/").filter(Boolean);
72
149
  const parts = segmentosCanonicos(pathname);
@@ -103,50 +180,77 @@ function patronReenviable(patron) {
103
180
  if (segs.length === 0) return false;
104
181
  return !(segs.length === 1 && (segs[0] === "*" || segs[0].startsWith(":")));
105
182
  }
183
+ function lecturaDeBot(input) {
184
+ const method = input.method.toUpperCase();
185
+ if (method !== "GET" && method !== "HEAD") return null;
186
+ const h = input.headers;
187
+ if (h.has(PEAJE_ORIGIN_HEADER) || h.has(PEAJE_FETCH_HEADER) || h.has(PEAJE_VERIFICADOR_HEADER)) return null;
188
+ const ua = h.get("user-agent");
189
+ if (!ua || !tipoDeBot(ua)) return null;
190
+ const path = input.pathname;
191
+ if (!path.startsWith("/") || path.startsWith("/_next/") || path.startsWith("/_vercel/") || NO_ES_PAGINA.test(path)) return null;
192
+ return { path: path.slice(0, MAX_PATH_LECTURA), method, userAgent: ua.slice(0, MAX_UA_LECTURA) };
193
+ }
106
194
  function crearProxyRuntime(opciones) {
107
195
  const base = opciones.base.replace(/\/+$/, "");
108
196
  const ttl = opciones.ttlMs ?? 6e4;
109
197
  const fetchImpl = opciones.fetchImpl ?? fetch;
110
- let cache = {
111
- at: 0,
112
- rutas: opciones.respaldo?.rutas ?? [],
113
- prefijos: opciones.respaldo?.prefijos ?? [],
114
- patrones: opciones.respaldo?.patrones ?? []
115
- };
198
+ const rutas = [...opciones.fijas?.rutas ?? RUTAS_PROXY.map((r) => r.path)];
199
+ const prefijos = [...opciones.fijas?.prefijos ?? RUTAS_DINAMICAS.map((r) => r.prefijo)];
200
+ const prefijosApi = prefijosApiValidos(opciones.prefijosApi ?? PREFIJOS_API_POR_DEFECTO);
201
+ let cache = { at: 0, patrones: [] };
116
202
  let refrescando = null;
117
203
  async function refrescar() {
118
204
  try {
119
205
  const res = await fetchImpl(`${base}/kit/manifest.json`, { signal: AbortSignal.timeout(5e3) });
120
206
  if (!res.ok) throw new Error(String(res.status));
121
207
  const m = await res.json();
122
- cache = {
123
- at: Date.now(),
124
- rutas: Array.isArray(m.rutas) ? m.rutas : cache.rutas,
125
- prefijos: Array.isArray(m.prefijos) ? m.prefijos : cache.prefijos,
126
- patrones: Array.isArray(m.patrones) ? m.patrones : cache.patrones
127
- };
208
+ const patrones2 = Array.isArray(m.patrones) ? m.patrones.filter((p) => typeof p?.method === "string" && typeof p?.path === "string") : cache.patrones;
209
+ cache = { at: Date.now(), patrones: patrones2 };
128
210
  } catch {
129
211
  cache = { ...cache, at: Date.now() };
130
212
  } finally {
131
213
  refrescando = null;
132
214
  }
133
215
  }
134
- async function listas() {
216
+ async function patrones() {
135
217
  const vencido = Date.now() - cache.at >= ttl;
136
218
  if (vencido) {
137
219
  refrescando ??= refrescar();
138
220
  if (cache.at === 0) await refrescando;
139
221
  }
140
- return cache;
222
+ return cache.patrones;
141
223
  }
142
224
  return {
225
+ /** Los prefijos de API efectivos, normalizados (para los avisos). */
226
+ prefijosApi,
143
227
  /** Rutas de API con precio que este sitio no puede cobrar por falta de secreto. */
144
228
  async sinSecreto() {
145
229
  if (opciones.secreto) return [];
146
- return (await listas()).patrones.filter((p) => patronReenviable(p.path));
230
+ return (await patrones()).filter((p) => patronPermitido(p.path, prefijosApi));
231
+ },
232
+ /** Rutas de API con precio que no se reenvían por caer fuera de `prefijosApi` o en un path reservado. */
233
+ async fueraDePrefijos() {
234
+ return (await patrones()).filter((p) => patronReenviable(p.path) && !patronPermitido(p.path, prefijosApi));
147
235
  },
148
236
  /** Fuerza la carga del manifiesto (útil al arrancar o en tests). */
149
237
  cargar: () => refrescando ??= refrescar(),
238
+ /**
239
+ * Si la request es un bot leyendo una página del sitio, le avisa al
240
+ * gateway y devuelve la promesa del aviso (para `waitUntil`); si no, null.
241
+ * El aviso nunca lanza ni demora la página: 3 s de tope y se descarta la
242
+ * respuesta.
243
+ */
244
+ reportarLectura(input) {
245
+ const lectura = lecturaDeBot(input);
246
+ if (!lectura) return null;
247
+ return fetchImpl(`${base}/kit/visita`, {
248
+ method: "POST",
249
+ headers: { "content-type": "application/json" },
250
+ body: JSON.stringify(lectura),
251
+ signal: AbortSignal.timeout(3e3)
252
+ }).then((res) => res.body?.cancel()).catch(() => void 0);
253
+ },
150
254
  /**
151
255
  * Decide si la request va al gateway. `method` y `pathname` de la request,
152
256
  * `search` con el `?` incluido, y los headers para cortar bucles.
@@ -155,14 +259,18 @@ function crearProxyRuntime(opciones) {
155
259
  const secreto = opciones.secreto || null;
156
260
  const prueba = input.headers.get(PEAJE_ORIGIN_HEADER);
157
261
  if (secreto && prueba && iguales(prueba, secreto)) return null;
158
- const { rutas, prefijos, patrones } = await listas();
159
262
  const path = input.pathname;
160
263
  const canon = pathCanonico(path).toLowerCase();
161
264
  const esFetchDePeaje = input.headers.get(PEAJE_FETCH_HEADER) !== null;
162
265
  const fija = (rutas.includes(path) || rutas.some((r) => r.toLowerCase() === canon)) && !(canon === "/llms.txt" && esFetchDePeaje);
163
266
  const prefijo = prefijos.some((p) => path.startsWith(p) || canon.startsWith(p.toLowerCase()));
164
- const method = input.method.toUpperCase();
165
- const patron = secreto !== null && patrones.some((p) => p.method.toUpperCase() === method && patronReenviable(p.path) && coincidePatron(p.path, path));
267
+ let patron = false;
268
+ if (!fija && !prefijo && secreto !== null && !esReservado(path) && prefijosApi.some((pre) => canon.startsWith(pre))) {
269
+ const method = input.method.toUpperCase();
270
+ patron = (await patrones()).some(
271
+ (p) => p.method.toUpperCase() === method && patronPermitido(p.path, prefijosApi) && coincidePatron(p.path, path)
272
+ );
273
+ }
166
274
  if (!fija && !prefijo && !patron) return null;
167
275
  return { destino: `${base}${path}${input.search ?? ""}` };
168
276
  }
@@ -307,6 +415,24 @@ function baseDe({ slug, gateway }) {
307
415
  }
308
416
  var SALTO = ["host", "connection", "keep-alive", "proxy-connection", "transfer-encoding", "upgrade", "te", "trailer", "content-length"];
309
417
  var avisado = false;
418
+ function avisar(runtime, conSecreto) {
419
+ if (!conSecreto) {
420
+ void runtime.sinSecreto().then((faltan) => {
421
+ if (faltan.length > 0) {
422
+ console.warn(
423
+ `[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.`
424
+ );
425
+ }
426
+ });
427
+ }
428
+ void runtime.fueraDePrefijos().then((fuera) => {
429
+ if (fuera.length > 0) {
430
+ console.warn(
431
+ `[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.`
432
+ );
433
+ }
434
+ });
435
+ }
310
436
  function _reiniciarAviso() {
311
437
  avisado = false;
312
438
  }
@@ -315,24 +441,14 @@ function crearPeaje(opciones) {
315
441
  const base = baseDe(opciones);
316
442
  const secreto = opciones.secret ?? env("PEAJE_ORIGIN_SECRET") ?? null;
317
443
  const fetchImpl = opciones.fetch ?? ((...a) => fetch(...a));
318
- const runtime = crearProxyRuntime({
319
- base,
320
- secreto,
321
- fetchImpl,
322
- respaldo: { rutas: RUTAS_PROXY.map((x) => x.path), prefijos: RUTAS_DINAMICAS.map((x) => x.prefijo), patrones: [] }
323
- });
444
+ const runtime = crearProxyRuntime({ base, secreto, fetchImpl, prefijosApi: opciones.apiPrefixes ?? PREFIJOS_API_POR_DEFECTO });
445
+ const reportar = opciones.reportBotVisits !== false;
324
446
  return {
325
447
  base,
326
448
  async decidir({ method, url, headers }) {
327
- if (!secreto && !avisado) {
449
+ if (!avisado) {
328
450
  avisado = true;
329
- void runtime.sinSecreto().then((faltan) => {
330
- if (faltan.length > 0) {
331
- console.warn(
332
- `[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.`
333
- );
334
- }
335
- });
451
+ avisar(runtime, Boolean(secreto));
336
452
  }
337
453
  const d = await runtime.decidir({ method, pathname: url.pathname, search: url.search, headers });
338
454
  return d?.destino ?? null;
@@ -340,6 +456,7 @@ function crearPeaje(opciones) {
340
456
  async reenviar(destino, { method, url, headers, body }) {
341
457
  const salida = new Headers(headers);
342
458
  for (const h of SALTO) salida.delete(h);
459
+ for (const h of HEADERS_NO_REENVIADOS) salida.delete(h);
343
460
  const primero = (h) => headers.get(h)?.split(",")[0]?.trim() || null;
344
461
  const host = primero("x-forwarded-host") ?? url.host;
345
462
  salida.set("x-forwarded-host", host);
@@ -353,16 +470,26 @@ function crearPeaje(opciones) {
353
470
  vuelta.delete("transfer-encoding");
354
471
  vuelta.delete("connection");
355
472
  return new Response(method === "HEAD" ? null : res.body, { status: res.status, statusText: res.statusText, headers: vuelta });
473
+ },
474
+ reportarLectura({ method, url, headers }) {
475
+ return reportar ? runtime.reportarLectura({ method, pathname: url.pathname, headers }) : null;
356
476
  }
357
477
  };
358
478
  }
359
479
  function peajeFetch(opciones) {
360
480
  const peaje = crearPeaje(opciones);
361
- return async (request) => {
481
+ return async (request, waitUntil) => {
362
482
  const url = new URL(request.url);
363
483
  const entrada = { method: request.method.toUpperCase(), url, headers: request.headers };
364
484
  const destino = await peaje.decidir(entrada);
365
- if (!destino) return null;
485
+ if (!destino) {
486
+ const lectura = peaje.reportarLectura(entrada);
487
+ if (lectura) {
488
+ if (waitUntil) waitUntil(lectura);
489
+ else void lectura;
490
+ }
491
+ return null;
492
+ }
366
493
  const body = entrada.method === "GET" || entrada.method === "HEAD" ? null : await request.arrayBuffer();
367
494
  return peaje.reenviar(destino, { ...entrada, body });
368
495
  };
package/dist/index.d.cts CHANGED
@@ -10,6 +10,19 @@ type OpcionesPeaje = {
10
10
  secret?: string;
11
11
  /** URL pública del gateway. Por defecto `PEAJE_GATEWAY_URL` o la de producción. */
12
12
  gateway?: string;
13
+ /**
14
+ * Prefijos bajo los que viven tus rutas de API con precio. Una ruta del
15
+ * dashboard fuera de ellos cobra solo en la URL del gateway, no en tu
16
+ * dominio. Por defecto `['/api/', '/v1/']`. La raíz y los paths reservados
17
+ * (`/login`, `/auth`, `/api/auth`, `/_next`...) nunca se aceptan.
18
+ */
19
+ apiPrefixes?: string[];
20
+ /**
21
+ * Avisarle al gateway cuando un bot de IA lee una página de la app, para el
22
+ * registro de visitas. Solo viajan la ruta (sin query) y el user-agent del
23
+ * bot. Por defecto `true`.
24
+ */
25
+ reportBotVisits?: boolean;
13
26
  /** Para tests o runtimes sin fetch global. */
14
27
  fetch?: typeof fetch;
15
28
  };
@@ -32,15 +45,23 @@ type Peaje = {
32
45
  reenviar(destino: string, entrada: EntradaDecision & {
33
46
  body: BodyInit | null;
34
47
  }): Promise<Response>;
48
+ /**
49
+ * Para una request que no fue al gateway: si es un bot de IA leyendo una
50
+ * página, se lo avisa al gateway y devuelve la promesa del aviso (para
51
+ * `waitUntil`). Null si no hay nada que avisar. Nunca lanza.
52
+ */
53
+ reportarLectura(entrada: EntradaDecision): Promise<void> | null;
35
54
  };
36
55
  declare function baseDe({ slug, gateway }: Pick<OpcionesPeaje, 'slug' | 'gateway'>): string;
37
56
  /** Solo para tests: vuelve a permitir el aviso de secreto faltante. */
38
57
  declare function _reiniciarAviso(): void;
39
58
  declare function crearPeaje(opciones: OpcionesPeaje): Peaje;
40
- type PeajeFetch = (request: Request) => Promise<Response | null>;
59
+ type PeajeFetch = (request: Request, waitUntil?: (promesa: Promise<unknown>) => void) => Promise<Response | null>;
41
60
  /**
42
61
  * `(request) => Response | null`. Null: la request no es de Peaje y la
43
- * atiende la app. El body se lee (en memoria) solo si va al gateway.
62
+ * atiende la app. El body se lee (en memoria) solo si va al gateway. Con
63
+ * `waitUntil` (Workers, Vercel), el aviso de lectura de un bot corre ahí;
64
+ * sin él, al costado.
44
65
  */
45
66
  declare function peajeFetch(opciones: OpcionesPeaje): PeajeFetch;
46
67
 
package/dist/index.d.ts CHANGED
@@ -10,6 +10,19 @@ type OpcionesPeaje = {
10
10
  secret?: string;
11
11
  /** URL pública del gateway. Por defecto `PEAJE_GATEWAY_URL` o la de producción. */
12
12
  gateway?: string;
13
+ /**
14
+ * Prefijos bajo los que viven tus rutas de API con precio. Una ruta del
15
+ * dashboard fuera de ellos cobra solo en la URL del gateway, no en tu
16
+ * dominio. Por defecto `['/api/', '/v1/']`. La raíz y los paths reservados
17
+ * (`/login`, `/auth`, `/api/auth`, `/_next`...) nunca se aceptan.
18
+ */
19
+ apiPrefixes?: string[];
20
+ /**
21
+ * Avisarle al gateway cuando un bot de IA lee una página de la app, para el
22
+ * registro de visitas. Solo viajan la ruta (sin query) y el user-agent del
23
+ * bot. Por defecto `true`.
24
+ */
25
+ reportBotVisits?: boolean;
13
26
  /** Para tests o runtimes sin fetch global. */
14
27
  fetch?: typeof fetch;
15
28
  };
@@ -32,15 +45,23 @@ type Peaje = {
32
45
  reenviar(destino: string, entrada: EntradaDecision & {
33
46
  body: BodyInit | null;
34
47
  }): Promise<Response>;
48
+ /**
49
+ * Para una request que no fue al gateway: si es un bot de IA leyendo una
50
+ * página, se lo avisa al gateway y devuelve la promesa del aviso (para
51
+ * `waitUntil`). Null si no hay nada que avisar. Nunca lanza.
52
+ */
53
+ reportarLectura(entrada: EntradaDecision): Promise<void> | null;
35
54
  };
36
55
  declare function baseDe({ slug, gateway }: Pick<OpcionesPeaje, 'slug' | 'gateway'>): string;
37
56
  /** Solo para tests: vuelve a permitir el aviso de secreto faltante. */
38
57
  declare function _reiniciarAviso(): void;
39
58
  declare function crearPeaje(opciones: OpcionesPeaje): Peaje;
40
- type PeajeFetch = (request: Request) => Promise<Response | null>;
59
+ type PeajeFetch = (request: Request, waitUntil?: (promesa: Promise<unknown>) => void) => Promise<Response | null>;
41
60
  /**
42
61
  * `(request) => Response | null`. Null: la request no es de Peaje y la
43
- * atiende la app. El body se lee (en memoria) solo si va al gateway.
62
+ * atiende la app. El body se lee (en memoria) solo si va al gateway. Con
63
+ * `waitUntil` (Workers, Vercel), el aviso de lectura de un bot corre ahí;
64
+ * sin él, al costado.
44
65
  */
45
66
  declare function peajeFetch(opciones: OpcionesPeaje): PeajeFetch;
46
67
 
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  crearPeaje,
6
6
  peajeFetch,
7
7
  src_default
8
- } from "./chunk-IU5YMR4J.js";
8
+ } from "./chunk-SWB3HJVY.js";
9
9
  export {
10
10
  GATEWAY_POR_DEFECTO,
11
11
  _reiniciarAviso,