@browserlessapi/inferlo 0.5.0 → 0.6.0

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.
Files changed (83) hide show
  1. package/README.md +8 -2
  2. package/dist/ai/index.d.ts +7 -0
  3. package/dist/ai/index.js +68 -2
  4. package/dist/ai/index.js.map +1 -1
  5. package/dist/ai/public.d.ts +1 -0
  6. package/dist/ai/public.js.map +1 -1
  7. package/dist/api-version.d.ts +32 -0
  8. package/dist/api-version.js +45 -0
  9. package/dist/api-version.js.map +1 -0
  10. package/dist/app.d.ts +30 -3
  11. package/dist/app.js +210 -43
  12. package/dist/app.js.map +1 -1
  13. package/dist/audit.d.ts +50 -0
  14. package/dist/audit.js +92 -0
  15. package/dist/audit.js.map +1 -0
  16. package/dist/cache.d.ts +92 -0
  17. package/dist/cache.js +245 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/cli/main.js +25 -4
  20. package/dist/cli/main.js.map +1 -1
  21. package/dist/describe.d.ts +42 -1
  22. package/dist/describe.js +25 -2
  23. package/dist/describe.js.map +1 -1
  24. package/dist/fastify.d.ts +10 -2
  25. package/dist/fastify.js +22 -4
  26. package/dist/fastify.js.map +1 -1
  27. package/dist/h3.d.ts +76 -0
  28. package/dist/h3.js +61 -0
  29. package/dist/h3.js.map +1 -0
  30. package/dist/hapi.d.ts +45 -0
  31. package/dist/hapi.js +57 -0
  32. package/dist/hapi.js.map +1 -0
  33. package/dist/index.d.ts +14 -1
  34. package/dist/index.js +5 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/jobs.d.ts +20 -0
  37. package/dist/jobs.js +19 -7
  38. package/dist/jobs.js.map +1 -1
  39. package/dist/koa.d.ts +51 -0
  40. package/dist/koa.js +45 -0
  41. package/dist/koa.js.map +1 -0
  42. package/dist/lambda.d.ts +110 -0
  43. package/dist/lambda.js +169 -0
  44. package/dist/lambda.js.map +1 -0
  45. package/dist/mcp.d.ts +1 -1
  46. package/dist/mcp.js +7 -3
  47. package/dist/mcp.js.map +1 -1
  48. package/dist/module.d.ts +52 -0
  49. package/dist/module.js +152 -0
  50. package/dist/module.js.map +1 -0
  51. package/dist/nextjs.d.ts +35 -0
  52. package/dist/nextjs.js +33 -0
  53. package/dist/nextjs.js.map +1 -0
  54. package/dist/openapi.d.ts +3 -0
  55. package/dist/openapi.js +12 -3
  56. package/dist/openapi.js.map +1 -1
  57. package/dist/permissions.d.ts +26 -0
  58. package/dist/permissions.js +78 -0
  59. package/dist/permissions.js.map +1 -0
  60. package/dist/react-router.d.ts +41 -0
  61. package/dist/react-router.js +42 -0
  62. package/dist/react-router.js.map +1 -0
  63. package/dist/request-matcher.d.ts +4 -0
  64. package/dist/request-matcher.js +14 -0
  65. package/dist/request-matcher.js.map +1 -0
  66. package/dist/route.d.ts +36 -1
  67. package/dist/route.js +24 -0
  68. package/dist/route.js.map +1 -1
  69. package/dist/serve.d.ts +20 -1
  70. package/dist/serve.js +48 -4
  71. package/dist/serve.js.map +1 -1
  72. package/dist/sessions.d.ts +72 -0
  73. package/dist/sessions.js +171 -0
  74. package/dist/sessions.js.map +1 -0
  75. package/dist/sveltekit.d.ts +72 -0
  76. package/dist/sveltekit.js +68 -0
  77. package/dist/sveltekit.js.map +1 -0
  78. package/dist/tracing.d.ts +118 -0
  79. package/dist/tracing.js +204 -0
  80. package/dist/tracing.js.map +1 -0
  81. package/llms.txt +7 -3
  82. package/package.json +44 -2
  83. package/templates/app/AGENTS.md +5 -3
package/dist/app.js CHANGED
@@ -4,21 +4,28 @@
4
4
  */
5
5
  import { bindTools, createAI } from "./ai/index.js";
6
6
  import { memory } from "./db/memory.js";
7
+ import { createAudit } from "./audit.js";
8
+ import { checkRouteCache, createCache, databaseCacheStore, etagFor, etagMatches, memoryCacheStore, resolveCacheConfig, routeCacheKey } from "./cache.js";
7
9
  import { bindDb, createDb, guardAdapter } from "./db/table.js";
8
10
  import { describeApp } from "./describe.js";
9
11
  import { InferloError, didYouMean, suggest } from "./errors.js";
10
12
  import { callTool, handleMcp, toolDefinition } from "./mcp.js";
11
13
  import { buildOpenAPI } from "./openapi.js";
12
- import { isRoute } from "./route.js";
14
+ import { collectRoutes } from "./route.js";
13
15
  import { checkRateLimit, databaseRateLimitStore, memoryRateLimitStore, rateLimitResult, windowMs } from "./rate-limit.js";
14
16
  import { clientIp, clientIps } from "./client-ip.js";
15
17
  import { collectCrons, collectJobs, createJobs, jobStoreFor, stopWorkers } from "./jobs.js";
16
18
  import { ValidationError } from "./schema.js";
17
19
  import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
18
20
  import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
21
+ import { clientTracing, jobTracing, requestTracing, traceAdapter, traceFields } from "./tracing.js";
19
22
  import { resolveDeprecation } from "./deprecation.js";
20
23
  import { createMetrics, hasMetricsToken, resolveMetrics } from "./metrics.js";
21
24
  import { INFERLO_VERSION } from "./describe.js";
25
+ import { registerRequestMatcher } from "./request-matcher.js";
26
+ import { versionDocPath, versionsOf } from "./api-version.js";
27
+ import { resolveModules } from "./module.js";
28
+ import { isPermissions } from "./permissions.js";
22
29
  import { applySecureHeaders, resolveSecureHeaders } from "./secure-headers.js";
23
30
  const testUsers = new WeakMap();
24
31
  function compile(def) {
@@ -45,29 +52,6 @@ function compile(def) {
45
52
  .join("");
46
53
  return { def, regex: new RegExp(`^${pattern || ""}/?$`), keys, score: score * 10 + parts.length };
47
54
  }
48
- function collectRoutes(sources) {
49
- const out = [];
50
- const seen = new Set();
51
- const visit = (source, name) => {
52
- if (!source || typeof source !== "object" || seen.has(source))
53
- return;
54
- seen.add(source);
55
- if (isRoute(source)) {
56
- out.push(name && !source.name ? { ...source, name } : source);
57
- return;
58
- }
59
- if (Array.isArray(source)) {
60
- for (const item of source)
61
- visit(item);
62
- return;
63
- }
64
- for (const [key, value] of Object.entries(source))
65
- if (isRoute(value) || Array.isArray(value))
66
- visit(value, key === "default" ? undefined : key);
67
- };
68
- visit(sources);
69
- return out;
70
- }
71
55
  function corsHeaders(config, origin) {
72
56
  const allowed = config.origin === "*" ? "*" : origin && (Array.isArray(config.origin) ? config.origin : [config.origin]).includes(origin) ? origin : null;
73
57
  if (!allowed)
@@ -138,7 +122,12 @@ export function createApp(config = {}) {
138
122
  const redactor = createRedactor([...redactValues(config.redact), ...Object.values(config.ai?.providers ?? {}).flatMap((p) => secretsOf(p))]);
139
123
  const logging = loggingOptions(config.logging, config.logger !== undefined);
140
124
  const logger = redactingLogger(config.logger ?? defaultLogger(logging.format, config.name ?? "inferlo-app"), redactor);
141
- const models = (config.models ?? []);
125
+ const tracing = requestTracing(config.tracing, config.name ?? "inferlo-app", config.version ?? "0.0.0");
126
+ const tracingApi = tracing ? config.tracing.api : undefined;
127
+ // The server span of each request, for errors recorded while it runs.
128
+ const requestSpans = new WeakMap();
129
+ const { modules, modelOwner } = resolveModules(config.modules, config.models ?? []);
130
+ const models = [...(config.models ?? []), ...modules.flatMap((m) => m.models)];
142
131
  const modelNames = new Set();
143
132
  for (const m of models) {
144
133
  if (modelNames.has(m.name))
@@ -155,7 +144,15 @@ export function createApp(config = {}) {
155
144
  }
156
145
  }
157
146
  }
147
+ // Routes of the app itself, then each module's; routeModule remembers which module a route came from.
148
+ const routeModule = new Map();
158
149
  const routes = collectRoutes(config.routes);
150
+ for (const m of modules) {
151
+ for (const r of collectRoutes(m.routes)) {
152
+ routeModule.set(r, m.name);
153
+ routes.push(r);
154
+ }
155
+ }
159
156
  const compiled = routes.map(compile).sort((a, b) => b.score - a.score);
160
157
  const signatures = new Map();
161
158
  for (const r of routes) {
@@ -180,9 +177,29 @@ export function createApp(config = {}) {
180
177
  fix: 'Routes are private by default. Add auth: "public" to routes anyone may call, or pass createApp({ auth: { resolve(req) { ... } } }).',
181
178
  });
182
179
  }
180
+ if (config.permissions !== undefined && !isPermissions(config.permissions)) {
181
+ throw new InferloError("IL6026", "createApp({ permissions }) takes the result of permissions({ ... }).", { fix: 'Define them with permissions({ "tickets.update": ({ user, ticket }) => ticket.ownerId === user.id }) and pass that value.' });
182
+ }
183
+ for (const r of routes) {
184
+ const name = r.options.permission;
185
+ if (name === undefined)
186
+ continue;
187
+ const where = `${r.method} ${r.path}`;
188
+ if (typeof name !== "string" || !config.permissions?.has(name)) {
189
+ throw new InferloError("IL6026", config.permissions ? `The route ${where} uses the unknown permission "${String(name)}".` : `The route ${where} uses the permission "${String(name)}", but createApp has no permissions.`, {
190
+ fix: config.permissions ? `Use one of: ${config.permissions.list.map((p) => p.name).join(", ")}, or add it to permissions({ ... }).` : "Pass createApp({ permissions }) with the value from permissions({ ... }).",
191
+ });
192
+ }
193
+ if (r.options.auth === "public") {
194
+ throw new InferloError("IL6026", `The route ${where} has a permission but auth: "public".`, { fix: 'A permission needs a signed-in user: remove auth: "public" from the route.' });
195
+ }
196
+ }
183
197
  for (const r of routes)
184
198
  if (r.options.rateLimit !== undefined)
185
199
  checkRateLimit(r.options.rateLimit, `${r.method} ${r.path}`);
200
+ for (const r of routes)
201
+ if (r.options.cache !== undefined)
202
+ checkRouteCache(r.options.cache, r.method, `${r.method} ${r.path}`);
186
203
  const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
187
204
  for (const r of routes)
188
205
  if (r.options.timeoutMs !== undefined)
@@ -201,32 +218,59 @@ export function createApp(config = {}) {
201
218
  const hasTools = routes.some((r) => r.options.tool);
202
219
  const mcpPath = config.mcp === undefined ? (hasTools ? "/mcp" : false) : config.mcp;
203
220
  const health = resolveHealth(config.health, routes, [openapiPath, mcpPath]);
221
+ // apiVersion("v1", ...) routes get their own OpenAPI document at /v1/openapi.json.
222
+ const versions = versionsOf(routes);
223
+ const versionDocs = new Map(openapiPath ? versions.map((v) => [versionDocPath(v, openapiPath), v]) : []);
224
+ for (const docPath of versionDocs.keys()) {
225
+ const clash = routes.find((r) => r.method === "GET" && r.path.replace(/\/+$/, "") === docPath);
226
+ if (clash)
227
+ throw new InferloError("IL6025", `The route GET ${clash.path} has the path of the OpenAPI document of API version ${versionDocs.get(docPath)}.`, { fix: "Rename the route; Inferlo serves each version's OpenAPI document there." });
228
+ }
204
229
  const metricsConfig = resolveMetrics(config.metrics, [
230
+ ...[...versionDocs.keys()].map((path) => ({ path, what: "an OpenAPI document path" })),
205
231
  { path: openapiPath, what: "the OpenAPI path" },
206
232
  { path: mcpPath, what: "the MCP path" },
207
233
  { path: health?.path ?? false, what: "the health path" },
208
234
  { path: health?.readyPath ?? false, what: "the readiness path" },
209
235
  ...routes.filter((r) => r.method === "GET").map((r) => ({ path: r.path, what: `the route GET ${r.path}` })),
210
236
  ]);
211
- const adapter = config.db ?? memory();
237
+ // With tracing, database queries made while a request or job is traced get client spans.
238
+ const appTracing = tracing ? { ...config.tracing, tracerName: config.tracing.tracerName ?? config.name ?? "inferlo-app" } : undefined;
239
+ const adapter = appTracing ? traceAdapter(config.db ?? memory(), clientTracing(appTracing, "inferlo-app", config.version ?? "0.0.0")) : (config.db ?? memory());
212
240
  const db = createDb(models, adapter);
213
241
  const rateLimits = storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
242
+ const cacheConfig = resolveCacheConfig(config.cache);
243
+ const cache = createCache(cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries), cacheConfig.store);
214
244
  const metrics = metricsConfig
215
245
  ? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
216
246
  : undefined;
217
247
  // With metrics on, every AI call that reports usage is counted before the app's own onUsage runs.
218
- const aiConfig = metrics && config.ai
219
- ? { ...config.ai, onUsage: (usage) => (metrics.ai(usage), config.ai.onUsage?.(usage)) }
248
+ // With tracing, AI calls get client spans too (unless the AI config has its own tracing).
249
+ const aiConfig = config.ai && (metrics || appTracing)
250
+ ? {
251
+ ...config.ai,
252
+ ...(metrics ? { onUsage: (usage) => (metrics.ai(usage), config.ai.onUsage?.(usage)) } : {}),
253
+ ...(appTracing ? { tracing: config.ai.tracing ?? appTracing } : {}),
254
+ }
220
255
  : config.ai;
221
256
  const ai = createAI(aiConfig);
222
- const jobs = createJobs(collectJobs(config.jobs), {
257
+ const jobModule = new Map();
258
+ for (const m of modules)
259
+ for (const j of collectJobs(m.jobs))
260
+ jobModule.set(j.name, m.name);
261
+ const moduleInfo = { modules, routeModule, modelOwner, jobModule };
262
+ const jobSources = [...(config.jobs ?? []), ...modules.flatMap((m) => m.jobs)];
263
+ const jobs = createJobs(collectJobs(jobSources), {
223
264
  db: db,
224
265
  ai,
266
+ cache,
267
+ audit: (run) => createAudit(models, redactor, () => db, { userId: null, source: `job ${run.name}` }),
225
268
  store: jobStoreFor(config.jobQueue, adapter),
226
269
  queue: config.jobQueue?.store ?? "memory",
227
270
  logger,
228
271
  onRun: metrics ? (name, outcome, ms) => metrics.job(name, outcome, ms / 1000) : undefined,
229
- }, collectCrons(config.jobs));
272
+ tracing: appTracing ? jobTracing(appTracing, "inferlo-app", config.version ?? "0.0.0") : undefined,
273
+ }, collectCrons(jobSources));
230
274
  const maxBody = config.maxBodySize ?? 1024 * 1024;
231
275
  const secureHeaders = resolveSecureHeaders(config.secureHeaders);
232
276
  const cors = config.cors === true ? { origin: "*" } : config.cors || undefined;
@@ -307,7 +351,10 @@ export function createApp(config = {}) {
307
351
  }, describeTool, signal);
308
352
  const roles = (user) => config.auth?.roles?.(user) ?? (user.roles ?? []);
309
353
  const errorResponse = (error, headers, req, requestId) => {
310
- const fields = { requestId };
354
+ const span = requestSpans.get(req);
355
+ const fields = { requestId, ...traceFields(span) };
356
+ if (span && !(error instanceof InferloError && error.status < 500))
357
+ span.recordException(error);
311
358
  if (error instanceof InferloError) {
312
359
  if (error.status >= 500)
313
360
  logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "", fields);
@@ -329,7 +376,7 @@ export function createApp(config = {}) {
329
376
  const method = req.method.toUpperCase();
330
377
  if (method === "OPTIONS" && cors) {
331
378
  headers.set("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
332
- headers.set("access-control-allow-headers", (cors.headers ?? ["content-type", "authorization"]).join(", "));
379
+ headers.set("access-control-allow-headers", (cors.headers ?? ["content-type", "authorization", "x-csrf-token"]).join(", "));
333
380
  headers.set("access-control-max-age", String(cors.maxAge ?? 600));
334
381
  return new Response(null, { status: 204, headers });
335
382
  }
@@ -352,13 +399,33 @@ export function createApp(config = {}) {
352
399
  headers.set("connection", "close");
353
400
  throw new InferloError("IL2006", "The server is shutting down.", { status: 503, fix: "Retry the request; another instance will answer it." });
354
401
  }
402
+ if (method === "GET" && versionDocs.has(url.pathname)) {
403
+ seen.route = url.pathname;
404
+ return json(app.openapi({ version: versionDocs.get(url.pathname) }), 200, headers);
405
+ }
355
406
  if (openapiPath && url.pathname === openapiPath && method === "GET") {
356
407
  seen.route = openapiPath;
357
408
  return json(app.openapi(), 200, headers);
358
409
  }
359
410
  if (mcpPath && url.pathname === mcpPath) {
360
411
  seen.route = mcpPath;
361
- return await handleMcp(app, req, headers, testUsers);
412
+ // tools/list leaves out tools that need a role or permission the caller does not have.
413
+ let caller;
414
+ const visible = async (def) => {
415
+ const level = def.options.auth ?? "user";
416
+ if (!def.options.permission && (level === "public" || level === "user"))
417
+ return true;
418
+ caller ??= testUsers.has(req) ? Promise.resolve(testUsers.get(req) ?? null) : config.auth ? Promise.resolve(config.auth.resolve(req, { db: db, url })).then((u) => u ?? null) : Promise.resolve(null);
419
+ const user = await caller;
420
+ if (!user)
421
+ return false;
422
+ if (level !== "public" && level !== "user" && !roles(user).includes(level))
423
+ return false;
424
+ if (!def.options.permission)
425
+ return true;
426
+ return config.permissions.can(def.options.permission, { user }).catch(() => false);
427
+ };
428
+ return await handleMcp(app, req, headers, testUsers, visible);
362
429
  }
363
430
  const lookup = method === "HEAD" ? "GET" : method;
364
431
  let match;
@@ -406,6 +473,8 @@ export function createApp(config = {}) {
406
473
  if (level !== "user" && !roles(user).includes(level))
407
474
  throw new InferloError("IL3002", `This requires the "${level}" role.`, { status: 403 });
408
475
  }
476
+ if (opts.permission)
477
+ await config.permissions.authorize(opts.permission, { user });
409
478
  // Rate limit: counted per user (or IP without one), after auth so failed sign-ins on private routes do not use it up.
410
479
  if (opts.rateLimit) {
411
480
  const who = (opts.rateLimit.by ?? "user") === "user" && user ? `user:${user.id}` : `ip:${ip}`;
@@ -432,8 +501,12 @@ export function createApp(config = {}) {
432
501
  const type = req.headers.get("content-type") ?? "";
433
502
  let raw = undefined;
434
503
  if (text) {
435
- if (type.includes("application/x-www-form-urlencoded"))
436
- raw = Object.fromEntries(new URLSearchParams(text));
504
+ if (type.includes("application/x-www-form-urlencoded")) {
505
+ // _csrf is the sessions() CSRF field of HTML forms, checked before the handler and not part of the body.
506
+ const form = new URLSearchParams(text);
507
+ form.delete("_csrf");
508
+ raw = Object.fromEntries(form);
509
+ }
437
510
  else {
438
511
  try {
439
512
  raw = JSON.parse(text);
@@ -445,6 +518,34 @@ export function createApp(config = {}) {
445
518
  }
446
519
  body = parseOrThrow(opts.body, raw, "request body", type.includes("application/x-www-form-urlencoded"));
447
520
  }
521
+ // Response cache: after auth, permissions, rate limits and input checks, so a cached response is only
522
+ // served to callers who may call the route with valid input.
523
+ const routeCache = opts.cache;
524
+ const cacheKey = routeCache ? await routeCacheKey(def.method, def.path, routeCache, user?.id, url) : undefined;
525
+ const cacheHeaders = (outcome, etag) => {
526
+ headers.set("x-inferlo-cache", outcome);
527
+ headers.set("etag", etag);
528
+ if (!headers.has("cache-control"))
529
+ headers.set("cache-control", `${(routeCache.by ?? "user") === "public" && (opts.auth ?? "user") === "public" ? "public" : "private"}, no-cache`);
530
+ if ((routeCache.by ?? "user") === "user")
531
+ headers.append("vary", "Authorization, Cookie");
532
+ };
533
+ const cachedAnswer = (body, etag) => {
534
+ if (etagMatches(req.headers.get("if-none-match"), etag))
535
+ return new Response(null, { status: 304, headers });
536
+ headers.set("content-type", "application/json; charset=utf-8");
537
+ return new Response(method === "HEAD" ? null : body, { status: 200, headers });
538
+ };
539
+ if (cacheKey) {
540
+ const hit = await cache.get(cacheKey);
541
+ if (hit) {
542
+ for (const [name, value] of hit.headers)
543
+ headers.set(name, value);
544
+ cacheHeaders("HIT", hit.etag);
545
+ return cachedAnswer(JSON.stringify(hit.body), hit.etag);
546
+ }
547
+ }
548
+ const handlerHeaders = [];
448
549
  let status = 0;
449
550
  const ctx = {
450
551
  body,
@@ -459,24 +560,43 @@ export function createApp(config = {}) {
459
560
  signal,
460
561
  requestId,
461
562
  jobs,
563
+ cache,
462
564
  req,
463
565
  url,
464
566
  headers: req.headers,
465
567
  state,
466
568
  status: (code) => void (status = code),
467
- header: (name, value) => headers.set(name, value),
569
+ header: (name, value) => {
570
+ // Set-Cookie is added, not replaced, so a handler can set several cookies.
571
+ if (name.toLowerCase() === "set-cookie")
572
+ headers.append(name, value);
573
+ else
574
+ headers.set(name, value);
575
+ handlerHeaders.push([name.toLowerCase(), value]);
576
+ },
468
577
  waitUntil: (p) => void pending.push(p.catch((e) => logger.error("waitUntil task failed", e, { requestId }))),
578
+ audit: createAudit(models, redactor, () => ctx.db, { userId: user?.id ?? null, source: `${def.method} ${def.path}`, requestId }),
469
579
  };
470
580
  const result = await def.handler(ctx);
471
581
  if (result instanceof Response) {
472
582
  headers.forEach((v, k) => {
473
- if (!result.headers.has(k))
583
+ if (k !== "set-cookie" && !result.headers.has(k))
474
584
  result.headers.set(k, v);
475
585
  });
586
+ for (const cookie of headers.getSetCookie())
587
+ result.headers.append("set-cookie", cookie);
476
588
  return result;
477
589
  }
478
590
  if (result === undefined)
479
591
  return new Response(null, { status: status || 204, headers });
592
+ // Only plain 200 JSON answers are cached, never one that sets a cookie.
593
+ if (cacheKey && (status || 200) === 200 && !handlerHeaders.some(([name]) => name === "set-cookie")) {
594
+ const body = JSON.stringify(result);
595
+ const etag = await etagFor(body);
596
+ await cache.set(cacheKey, { body: JSON.parse(body), headers: handlerHeaders, etag }, { ttlMs: routeCache.ttlMs, tags: routeCache.tags ?? [] });
597
+ cacheHeaders("MISS", etag);
598
+ return cachedAnswer(body, etag);
599
+ }
480
600
  const response = json(result, status || 200, headers);
481
601
  return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
482
602
  };
@@ -540,8 +660,29 @@ export function createApp(config = {}) {
540
660
  }
541
661
  };
542
662
  async function handle(req, rest) {
543
- const started = performance.now();
544
663
  const url = new URL(req.url);
664
+ // Health and metrics endpoints are called every few seconds by load balancers and scrapers: not logged, counted or traced.
665
+ const probe = url.pathname === health?.path || url.pathname === health?.readyPath || url.pathname === metricsConfig?.path;
666
+ if (!tracing || probe)
667
+ return handleTraced(req, rest, url, probe, undefined);
668
+ return tracing.run(req, url, async (span) => {
669
+ requestSpans.set(req, span);
670
+ let status = 500;
671
+ try {
672
+ const response = await handleTraced(req, rest, url, probe, span);
673
+ status = response.status;
674
+ return response;
675
+ }
676
+ finally {
677
+ span.setAttribute("http.response.status_code", status);
678
+ if (status >= 500)
679
+ span.setStatus({ code: tracingApi.SpanStatusCode.ERROR });
680
+ span.end();
681
+ }
682
+ });
683
+ }
684
+ async function handleTraced(req, rest, url, probe, span) {
685
+ const started = performance.now();
545
686
  // Behind a trusted proxy, keep the id it sent so log entries line up across services.
546
687
  const incoming = config.trustProxy ? req.headers.get("x-request-id") : null;
547
688
  const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
@@ -567,15 +708,17 @@ export function createApp(config = {}) {
567
708
  response = errorResponse(error, new Headers(), req, requestId);
568
709
  }
569
710
  response = withHeader(response, "x-request-id", requestId);
570
- // Health and metrics endpoints are called every few seconds by load balancers and scrapers: not logged or counted.
571
- const probe = url.pathname === health?.path || url.pathname === health?.readyPath || url.pathname === metricsConfig?.path;
711
+ if (span && seen.route !== "unmatched") {
712
+ span.updateName(`${req.method.toUpperCase()} ${seen.route}`);
713
+ span.setAttribute("http.route", seen.route);
714
+ }
572
715
  if (!probe) {
573
716
  const elapsed = performance.now() - started;
574
717
  const method = req.method.toUpperCase();
575
718
  metrics?.request(method, seen.route, response.status, elapsed / 1000);
576
719
  if (logging.requests) {
577
720
  const durationMs = Math.round(elapsed * 10) / 10;
578
- logger.info(`${method} ${url.pathname} ${response.status} ${durationMs}ms`, undefined, { requestId, method, path: url.pathname, status: response.status, durationMs });
721
+ logger.info(`${method} ${url.pathname} ${response.status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status: response.status, durationMs });
579
722
  }
580
723
  }
581
724
  if (secureHeaders.length) {
@@ -603,9 +746,11 @@ export function createApp(config = {}) {
603
746
  version: config.version ?? "0.0.0",
604
747
  routes,
605
748
  models,
749
+ modules,
606
750
  db,
607
751
  ai,
608
752
  jobs,
753
+ cache,
609
754
  get closing() {
610
755
  return closing;
611
756
  },
@@ -706,12 +851,34 @@ export function createApp(config = {}) {
706
851
  requestTimeoutMs,
707
852
  logging: { format: config.logger ? "custom" : logging.format, requests: logging.requests },
708
853
  description: config.description,
854
+ modules: moduleInfo,
855
+ versions: versions.map((v) => ({ name: v, openapi: openapiPath ? versionDocPath(v, openapiPath) : false })),
856
+ permissions: config.permissions?.list.map((p) => (p.roles ? { name: p.name, roles: [...p.roles] } : { name: p.name })) ?? [],
709
857
  });
710
858
  },
711
- openapi() {
712
- return buildOpenAPI(app, { description: config.description, auth: Boolean(config.auth) });
859
+ openapi(options = {}) {
860
+ if (options.version !== undefined && !versions.includes(options.version)) {
861
+ throw new InferloError("IL6025", `The app has no API version ${JSON.stringify(options.version)}.${versions.length ? ` Its versions: ${versions.join(", ")}.` : " It has no apiVersion() routes."}`, {
862
+ fix: 'Pass a version used in apiVersion("v1", routes), or call app.openapi() for every route.',
863
+ });
864
+ }
865
+ return buildOpenAPI(app, { description: config.description, auth: Boolean(config.auth), modules: moduleInfo, version: options.version });
713
866
  },
714
867
  };
868
+ // Built-in endpoints with the methods dispatch answers them for (MCP takes every method).
869
+ const readEndpoints = [health?.path, health?.readyPath, metricsConfig?.path].filter((p) => typeof p === "string");
870
+ registerRequestMatcher(app, (rawMethod, pathname) => {
871
+ const method = rawMethod.toUpperCase();
872
+ if (mcpPath && pathname === mcpPath)
873
+ return true;
874
+ if (method === "GET" && ((openapiPath && pathname === openapiPath) || versionDocs.has(pathname)))
875
+ return true;
876
+ if ((method === "GET" || method === "HEAD") && readEndpoints.includes(pathname))
877
+ return true;
878
+ const lookup = method === "HEAD" ? "GET" : method;
879
+ // A CORS preflight belongs to the app when any of its routes has this path.
880
+ return compiled.some((c) => (method === "OPTIONS" || c.def.method === lookup) && c.regex.test(pathname));
881
+ });
715
882
  return app;
716
883
  }
717
884
  function resolveHealth(option, routes, taken) {