@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.
- package/README.md +8 -2
- package/dist/ai/index.d.ts +7 -0
- package/dist/ai/index.js +68 -2
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/public.d.ts +1 -0
- package/dist/ai/public.js.map +1 -1
- package/dist/api-version.d.ts +32 -0
- package/dist/api-version.js +45 -0
- package/dist/api-version.js.map +1 -0
- package/dist/app.d.ts +30 -3
- package/dist/app.js +210 -43
- package/dist/app.js.map +1 -1
- package/dist/audit.d.ts +50 -0
- package/dist/audit.js +92 -0
- package/dist/audit.js.map +1 -0
- package/dist/cache.d.ts +92 -0
- package/dist/cache.js +245 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli/main.js +25 -4
- package/dist/cli/main.js.map +1 -1
- package/dist/describe.d.ts +42 -1
- package/dist/describe.js +25 -2
- package/dist/describe.js.map +1 -1
- package/dist/fastify.d.ts +10 -2
- package/dist/fastify.js +22 -4
- package/dist/fastify.js.map +1 -1
- package/dist/h3.d.ts +76 -0
- package/dist/h3.js +61 -0
- package/dist/h3.js.map +1 -0
- package/dist/hapi.d.ts +45 -0
- package/dist/hapi.js +57 -0
- package/dist/hapi.js.map +1 -0
- package/dist/index.d.ts +14 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +20 -0
- package/dist/jobs.js +19 -7
- package/dist/jobs.js.map +1 -1
- package/dist/koa.d.ts +51 -0
- package/dist/koa.js +45 -0
- package/dist/koa.js.map +1 -0
- package/dist/lambda.d.ts +110 -0
- package/dist/lambda.js +169 -0
- package/dist/lambda.js.map +1 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.js +7 -3
- package/dist/mcp.js.map +1 -1
- package/dist/module.d.ts +52 -0
- package/dist/module.js +152 -0
- package/dist/module.js.map +1 -0
- package/dist/nextjs.d.ts +35 -0
- package/dist/nextjs.js +33 -0
- package/dist/nextjs.js.map +1 -0
- package/dist/openapi.d.ts +3 -0
- package/dist/openapi.js +12 -3
- package/dist/openapi.js.map +1 -1
- package/dist/permissions.d.ts +26 -0
- package/dist/permissions.js +78 -0
- package/dist/permissions.js.map +1 -0
- package/dist/react-router.d.ts +41 -0
- package/dist/react-router.js +42 -0
- package/dist/react-router.js.map +1 -0
- package/dist/request-matcher.d.ts +4 -0
- package/dist/request-matcher.js +14 -0
- package/dist/request-matcher.js.map +1 -0
- package/dist/route.d.ts +36 -1
- package/dist/route.js +24 -0
- package/dist/route.js.map +1 -1
- package/dist/serve.d.ts +20 -1
- package/dist/serve.js +48 -4
- package/dist/serve.js.map +1 -1
- package/dist/sessions.d.ts +72 -0
- package/dist/sessions.js +171 -0
- package/dist/sessions.js.map +1 -0
- package/dist/sveltekit.d.ts +72 -0
- package/dist/sveltekit.js +68 -0
- package/dist/sveltekit.js.map +1 -0
- package/dist/tracing.d.ts +118 -0
- package/dist/tracing.js +204 -0
- package/dist/tracing.js.map +1 -0
- package/llms.txt +7 -3
- package/package.json +44 -2
- 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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
571
|
-
|
|
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
|
-
|
|
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) {
|