@browserlessapi/inferlo 0.4.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 +21 -3
- package/dist/adonisjs.d.ts +33 -0
- package/dist/adonisjs.js +30 -0
- package/dist/adonisjs.js.map +1 -0
- package/dist/ai/index.d.ts +22 -8
- package/dist/ai/index.js +107 -15
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/providers.js +5 -4
- package/dist/ai/providers.js.map +1 -1
- package/dist/ai/public.d.ts +21 -0
- package/dist/ai/public.js +17 -0
- package/dist/ai/public.js.map +1 -0
- package/dist/ai/tool.d.ts +47 -0
- package/dist/ai/tool.js +85 -0
- package/dist/ai/tool.js.map +1 -0
- package/dist/ai-client.d.ts +3 -0
- package/dist/ai-client.js +8 -0
- package/dist/ai-client.js.map +1 -0
- 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 +117 -5
- package/dist/app.js +667 -156
- 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 +45 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/config.d.ts +16 -0
- package/dist/config.js +101 -0
- package/dist/config.js.map +1 -0
- package/dist/db/table.d.ts +31 -0
- package/dist/db/table.js +140 -1
- package/dist/db/table.js.map +1 -1
- package/dist/deprecation.d.ts +23 -0
- package/dist/deprecation.js +70 -0
- package/dist/deprecation.js.map +1 -0
- package/dist/describe.d.ts +68 -5
- package/dist/describe.js +38 -3
- package/dist/describe.js.map +1 -1
- package/dist/elysia.d.ts +49 -0
- package/dist/elysia.js +10 -0
- package/dist/elysia.js.map +1 -0
- package/dist/env.d.ts +2 -0
- package/dist/env.js +5 -4
- package/dist/env.js.map +1 -1
- package/dist/express.d.ts +38 -0
- package/dist/express.js +18 -0
- package/dist/express.js.map +1 -0
- package/dist/fastify.d.ts +52 -0
- package/dist/fastify.js +65 -0
- package/dist/fastify.js.map +1 -0
- 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/hono.d.ts +26 -0
- package/dist/hono.js +10 -0
- package/dist/hono.js.map +1 -0
- package/dist/index.d.ts +27 -5
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +24 -0
- package/dist/jobs.js +35 -6
- 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/logging.d.ts +23 -0
- package/dist/logging.js +87 -0
- package/dist/logging.js.map +1 -0
- package/dist/mcp.d.ts +1 -7
- package/dist/mcp.js +18 -4
- package/dist/mcp.js.map +1 -1
- package/dist/metrics.d.ts +48 -0
- package/dist/metrics.js +184 -0
- package/dist/metrics.js.map +1 -0
- package/dist/module.d.ts +52 -0
- package/dist/module.js +152 -0
- package/dist/module.js.map +1 -0
- package/dist/nestjs.d.ts +43 -0
- package/dist/nestjs.js +47 -0
- package/dist/nestjs.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 -1
- package/dist/openapi.js +16 -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/redact.d.ts +19 -0
- package/dist/redact.js +150 -0
- package/dist/redact.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 +54 -1
- package/dist/route.js +24 -0
- package/dist/route.js.map +1 -1
- package/dist/secure-headers.d.ts +8 -0
- package/dist/secure-headers.js +61 -0
- package/dist/secure-headers.js.map +1 -0
- package/dist/serve.d.ts +72 -1
- package/dist/serve.js +201 -61
- 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/dist/trpc.d.ts +57 -0
- package/dist/trpc.js +142 -0
- package/dist/trpc.js.map +1 -0
- package/dist/tsed.d.ts +32 -0
- package/dist/tsed.js +13 -0
- package/dist/tsed.js.map +1 -0
- package/llms.txt +15 -5
- package/package.json +99 -3
- package/templates/app/AGENTS.md +12 -6
- package/templates/app/_env.example +2 -0
- package/templates/app/src/app.ts +15 -8
- package/templates/app/src/config.ts +17 -0
- package/templates/app/src/server.ts +9 -9
- package/templates/app/src/routes/health.ts +0 -3
package/dist/app.js
CHANGED
|
@@ -4,16 +4,29 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { bindTools, createAI } from "./ai/index.js";
|
|
6
6
|
import { memory } from "./db/memory.js";
|
|
7
|
-
import {
|
|
7
|
+
import { createAudit } from "./audit.js";
|
|
8
|
+
import { checkRouteCache, createCache, databaseCacheStore, etagFor, etagMatches, memoryCacheStore, resolveCacheConfig, routeCacheKey } from "./cache.js";
|
|
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
|
-
import { collectCrons, collectJobs, createJobs, jobStoreFor } from "./jobs.js";
|
|
17
|
+
import { collectCrons, collectJobs, createJobs, jobStoreFor, stopWorkers } from "./jobs.js";
|
|
16
18
|
import { ValidationError } from "./schema.js";
|
|
19
|
+
import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
|
|
20
|
+
import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
|
|
21
|
+
import { clientTracing, jobTracing, requestTracing, traceAdapter, traceFields } from "./tracing.js";
|
|
22
|
+
import { resolveDeprecation } from "./deprecation.js";
|
|
23
|
+
import { createMetrics, hasMetricsToken, resolveMetrics } from "./metrics.js";
|
|
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";
|
|
29
|
+
import { applySecureHeaders, resolveSecureHeaders } from "./secure-headers.js";
|
|
17
30
|
const testUsers = new WeakMap();
|
|
18
31
|
function compile(def) {
|
|
19
32
|
if (!def.path.startsWith("/")) {
|
|
@@ -39,29 +52,6 @@ function compile(def) {
|
|
|
39
52
|
.join("");
|
|
40
53
|
return { def, regex: new RegExp(`^${pattern || ""}/?$`), keys, score: score * 10 + parts.length };
|
|
41
54
|
}
|
|
42
|
-
function collectRoutes(sources) {
|
|
43
|
-
const out = [];
|
|
44
|
-
const seen = new Set();
|
|
45
|
-
const visit = (source, name) => {
|
|
46
|
-
if (!source || typeof source !== "object" || seen.has(source))
|
|
47
|
-
return;
|
|
48
|
-
seen.add(source);
|
|
49
|
-
if (isRoute(source)) {
|
|
50
|
-
out.push(name && !source.name ? { ...source, name } : source);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
if (Array.isArray(source)) {
|
|
54
|
-
for (const item of source)
|
|
55
|
-
visit(item);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
for (const [key, value] of Object.entries(source))
|
|
59
|
-
if (isRoute(value) || Array.isArray(value))
|
|
60
|
-
visit(value, key === "default" ? undefined : key);
|
|
61
|
-
};
|
|
62
|
-
visit(sources);
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
55
|
function corsHeaders(config, origin) {
|
|
66
56
|
const allowed = config.origin === "*" ? "*" : origin && (Array.isArray(config.origin) ? config.origin : [config.origin]).includes(origin) ? origin : null;
|
|
67
57
|
if (!allowed)
|
|
@@ -71,13 +61,15 @@ function corsHeaders(config, origin) {
|
|
|
71
61
|
h["access-control-allow-credentials"] = "true";
|
|
72
62
|
return h;
|
|
73
63
|
}
|
|
74
|
-
async function readBody(req, limit) {
|
|
64
|
+
async function readBody(req, limit, signal) {
|
|
75
65
|
const length = Number(req.headers.get("content-length") ?? "0");
|
|
76
66
|
if (length > limit)
|
|
77
67
|
throw new InferloError("IL2004", `Request body is larger than ${limit} bytes.`, { status: 413 });
|
|
78
68
|
if (!req.body)
|
|
79
69
|
return "";
|
|
80
70
|
const reader = req.body.getReader();
|
|
71
|
+
// Stop reading a slow body once the request timed out.
|
|
72
|
+
signal?.addEventListener("abort", () => void reader.cancel().catch(() => { }), { once: true });
|
|
81
73
|
const chunks = [];
|
|
82
74
|
let size = 0;
|
|
83
75
|
while (true) {
|
|
@@ -107,16 +99,35 @@ function queryObject(url) {
|
|
|
107
99
|
}
|
|
108
100
|
return out;
|
|
109
101
|
}
|
|
102
|
+
/** Sets a header unless the response already has it; copies responses with immutable headers. */
|
|
103
|
+
function withHeader(response, name, value) {
|
|
104
|
+
if (response.headers.has(name))
|
|
105
|
+
return response;
|
|
106
|
+
try {
|
|
107
|
+
response.headers.set(name, value);
|
|
108
|
+
return response;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
const copy = new Response(response.body, response);
|
|
112
|
+
copy.headers.set(name, value);
|
|
113
|
+
return copy;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
110
116
|
function json(data, status, headers) {
|
|
111
117
|
headers.set("content-type", "application/json; charset=utf-8");
|
|
112
118
|
return new Response(JSON.stringify(data), { status, headers });
|
|
113
119
|
}
|
|
114
120
|
export function createApp(config = {}) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
121
|
+
// Everything the app logs goes through the redactor: credentials and secrets never reach the logs.
|
|
122
|
+
const redactor = createRedactor([...redactValues(config.redact), ...Object.values(config.ai?.providers ?? {}).flatMap((p) => secretsOf(p))]);
|
|
123
|
+
const logging = loggingOptions(config.logging, config.logger !== undefined);
|
|
124
|
+
const logger = redactingLogger(config.logger ?? defaultLogger(logging.format, config.name ?? "inferlo-app"), redactor);
|
|
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)];
|
|
120
131
|
const modelNames = new Set();
|
|
121
132
|
for (const m of models) {
|
|
122
133
|
if (modelNames.has(m.name))
|
|
@@ -133,7 +144,15 @@ export function createApp(config = {}) {
|
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
}
|
|
147
|
+
// Routes of the app itself, then each module's; routeModule remembers which module a route came from.
|
|
148
|
+
const routeModule = new Map();
|
|
136
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
|
+
}
|
|
137
156
|
const compiled = routes.map(compile).sort((a, b) => b.score - a.score);
|
|
138
157
|
const signatures = new Map();
|
|
139
158
|
for (const r of routes) {
|
|
@@ -158,23 +177,151 @@ export function createApp(config = {}) {
|
|
|
158
177
|
fix: 'Routes are private by default. Add auth: "public" to routes anyone may call, or pass createApp({ auth: { resolve(req) { ... } } }).',
|
|
159
178
|
});
|
|
160
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
|
+
}
|
|
161
197
|
for (const r of routes)
|
|
162
198
|
if (r.options.rateLimit !== undefined)
|
|
163
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}`);
|
|
203
|
+
const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
|
|
204
|
+
for (const r of routes)
|
|
205
|
+
if (r.options.timeoutMs !== undefined)
|
|
206
|
+
checkTimeout(r.options.timeoutMs, `${r.method} ${r.path} { timeoutMs }`);
|
|
207
|
+
const deprecations = new Map();
|
|
208
|
+
for (const r of routes) {
|
|
209
|
+
const resolved = resolveDeprecation(r.options.deprecated, `${r.method} ${r.path}`);
|
|
210
|
+
if (resolved)
|
|
211
|
+
deprecations.set(r, resolved);
|
|
212
|
+
}
|
|
164
213
|
const storeName = config.rateLimit?.store ?? "memory";
|
|
165
214
|
if (storeName !== "memory" && storeName !== "database") {
|
|
166
215
|
throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }) or { store: "database" }.' });
|
|
167
216
|
}
|
|
168
|
-
const
|
|
217
|
+
const openapiPath = config.openapi === undefined ? "/openapi.json" : config.openapi;
|
|
218
|
+
const hasTools = routes.some((r) => r.options.tool);
|
|
219
|
+
const mcpPath = config.mcp === undefined ? (hasTools ? "/mcp" : false) : config.mcp;
|
|
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
|
+
}
|
|
229
|
+
const metricsConfig = resolveMetrics(config.metrics, [
|
|
230
|
+
...[...versionDocs.keys()].map((path) => ({ path, what: "an OpenAPI document path" })),
|
|
231
|
+
{ path: openapiPath, what: "the OpenAPI path" },
|
|
232
|
+
{ path: mcpPath, what: "the MCP path" },
|
|
233
|
+
{ path: health?.path ?? false, what: "the health path" },
|
|
234
|
+
{ path: health?.readyPath ?? false, what: "the readiness path" },
|
|
235
|
+
...routes.filter((r) => r.method === "GET").map((r) => ({ path: r.path, what: `the route GET ${r.path}` })),
|
|
236
|
+
]);
|
|
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());
|
|
169
240
|
const db = createDb(models, adapter);
|
|
170
241
|
const rateLimits = storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
|
|
171
|
-
const
|
|
172
|
-
const
|
|
242
|
+
const cacheConfig = resolveCacheConfig(config.cache);
|
|
243
|
+
const cache = createCache(cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries), cacheConfig.store);
|
|
244
|
+
const metrics = metricsConfig
|
|
245
|
+
? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
|
|
246
|
+
: undefined;
|
|
247
|
+
// With metrics on, every AI call that reports usage is counted before the app's own onUsage runs.
|
|
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
|
+
}
|
|
255
|
+
: config.ai;
|
|
256
|
+
const ai = createAI(aiConfig);
|
|
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), {
|
|
264
|
+
db: db,
|
|
265
|
+
ai,
|
|
266
|
+
cache,
|
|
267
|
+
audit: (run) => createAudit(models, redactor, () => db, { userId: null, source: `job ${run.name}` }),
|
|
268
|
+
store: jobStoreFor(config.jobQueue, adapter),
|
|
269
|
+
queue: config.jobQueue?.store ?? "memory",
|
|
270
|
+
logger,
|
|
271
|
+
onRun: metrics ? (name, outcome, ms) => metrics.job(name, outcome, ms / 1000) : undefined,
|
|
272
|
+
tracing: appTracing ? jobTracing(appTracing, "inferlo-app", config.version ?? "0.0.0") : undefined,
|
|
273
|
+
}, collectCrons(jobSources));
|
|
173
274
|
const maxBody = config.maxBodySize ?? 1024 * 1024;
|
|
275
|
+
const secureHeaders = resolveSecureHeaders(config.secureHeaders);
|
|
174
276
|
const cors = config.cors === true ? { origin: "*" } : config.cors || undefined;
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
277
|
+
// Readiness: the database answers, every AI alias points to a configured provider, and the app's own checks pass.
|
|
278
|
+
const readinessChecks = {
|
|
279
|
+
database: async () => {
|
|
280
|
+
if (adapter.sql)
|
|
281
|
+
await adapter.sql("select 1");
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
const aiProviders = Object.keys(config.ai?.providers ?? {});
|
|
285
|
+
if (aiProviders.length || Object.keys(config.ai?.models ?? {}).length) {
|
|
286
|
+
readinessChecks.ai = () => {
|
|
287
|
+
for (const [alias, targets] of Object.entries(ai.aliases)) {
|
|
288
|
+
for (const target of targets) {
|
|
289
|
+
const provider = target.split(":")[0];
|
|
290
|
+
if (!aiProviders.includes(provider)) {
|
|
291
|
+
throw new InferloError("IL5001", `AI alias "${alias}" uses provider "${provider}", which is not configured.`, { status: 503, expose: true });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
Object.assign(readinessChecks, health?.checks);
|
|
298
|
+
const runCheck = async (name, check) => {
|
|
299
|
+
const started = Date.now();
|
|
300
|
+
const timeoutMs = health?.timeoutMs ?? 5000;
|
|
301
|
+
let timer;
|
|
302
|
+
const timedOut = Symbol("timeout");
|
|
303
|
+
try {
|
|
304
|
+
const outcome = await Promise.race([
|
|
305
|
+
Promise.resolve().then(check),
|
|
306
|
+
new Promise((resolve) => {
|
|
307
|
+
timer = setTimeout(() => resolve(timedOut), timeoutMs);
|
|
308
|
+
}),
|
|
309
|
+
]);
|
|
310
|
+
if (outcome === timedOut) {
|
|
311
|
+
logger.error(`Readiness check "${name}" timed out after ${timeoutMs} ms`);
|
|
312
|
+
return { status: "error", durationMs: Date.now() - started, error: `Timed out after ${timeoutMs} ms.` };
|
|
313
|
+
}
|
|
314
|
+
return { status: "ok", durationMs: Date.now() - started };
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
logger.error(`Readiness check "${name}" failed`, error);
|
|
318
|
+
const shown = (error instanceof InferloError && error.expose) || (config.exposeErrors && error instanceof Error);
|
|
319
|
+
return { status: "error", durationMs: Date.now() - started, error: shown ? redactor.text(error.message) : "Check failed." };
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
clearTimeout(timer);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
178
325
|
// Routes passed to ai.generate({ tools }) are matched to the registered route by method and path.
|
|
179
326
|
const registeredTool = (route) => {
|
|
180
327
|
const found = routes.find((r) => r.method === route?.method && r.path === route?.path);
|
|
@@ -189,7 +336,7 @@ export function createApp(config = {}) {
|
|
|
189
336
|
const { name, description, inputSchema } = registeredTool(route);
|
|
190
337
|
return { name, description, inputSchema };
|
|
191
338
|
};
|
|
192
|
-
const toolsFor = (req) => bindTools(ai, async (route, input) => {
|
|
339
|
+
const toolsFor = (req, signal) => bindTools(ai, async (route, input) => {
|
|
193
340
|
const tool = registeredTool(route);
|
|
194
341
|
const response = await callTool(app, tool, input, req, testUsers);
|
|
195
342
|
const text = await response.text();
|
|
@@ -201,22 +348,26 @@ export function createApp(config = {}) {
|
|
|
201
348
|
// Not JSON: the model sees the text as it is.
|
|
202
349
|
}
|
|
203
350
|
return { name: tool.name, status: response.status, output, text: text || String(response.status) };
|
|
204
|
-
}, describeTool);
|
|
351
|
+
}, describeTool, signal);
|
|
205
352
|
const roles = (user) => config.auth?.roles?.(user) ?? (user.roles ?? []);
|
|
206
|
-
const errorResponse = (error, headers, req) => {
|
|
353
|
+
const errorResponse = (error, headers, req, 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);
|
|
207
358
|
if (error instanceof InferloError) {
|
|
208
359
|
if (error.status >= 500)
|
|
209
|
-
logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "");
|
|
360
|
+
logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "", fields);
|
|
210
361
|
const body = error.toJSON();
|
|
211
362
|
if (config.exposeErrors && !error.expose)
|
|
212
|
-
body.error.message = error.message;
|
|
363
|
+
body.error.message = redactor.text(error.message);
|
|
213
364
|
return json(body, error.status, headers);
|
|
214
365
|
}
|
|
215
|
-
logger.error(`${req.method} ${new URL(req.url).pathname} failed`, error);
|
|
216
|
-
const message = config.exposeErrors && error instanceof Error ? error.message : "Internal server error";
|
|
366
|
+
logger.error(`${req.method} ${new URL(req.url).pathname} failed`, error, fields);
|
|
367
|
+
const message = config.exposeErrors && error instanceof Error ? redactor.text(error.message) : "Internal server error";
|
|
217
368
|
return json({ error: { code: "IL9000", message, docs: new InferloError("IL9000", "").docs } }, 500, headers);
|
|
218
369
|
};
|
|
219
|
-
const dispatch = async (req, url, state, pending, ip) => {
|
|
370
|
+
const dispatch = async (req, url, state, pending, ip, requestId, seen) => {
|
|
220
371
|
const headers = new Headers();
|
|
221
372
|
if (cors)
|
|
222
373
|
for (const [k, v] of Object.entries(corsHeaders(cors, req.headers.get("origin"))))
|
|
@@ -225,14 +376,57 @@ export function createApp(config = {}) {
|
|
|
225
376
|
const method = req.method.toUpperCase();
|
|
226
377
|
if (method === "OPTIONS" && cors) {
|
|
227
378
|
headers.set("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
228
|
-
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(", "));
|
|
229
380
|
headers.set("access-control-max-age", String(cors.maxAge ?? 600));
|
|
230
381
|
return new Response(null, { status: 204, headers });
|
|
231
382
|
}
|
|
232
|
-
if (
|
|
383
|
+
if (health && (method === "GET" || method === "HEAD") && (url.pathname === health.path || url.pathname === health.readyPath)) {
|
|
384
|
+
headers.set("cache-control", "no-store");
|
|
385
|
+
const report = url.pathname === health.path ? { status: "ok" } : await app.checkReady();
|
|
386
|
+
const response = json(report, report.status === "not_ready" ? 503 : 200, headers);
|
|
387
|
+
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
388
|
+
}
|
|
389
|
+
if (metricsConfig && url.pathname === metricsConfig.path && (method === "GET" || method === "HEAD")) {
|
|
390
|
+
headers.set("cache-control", "no-store");
|
|
391
|
+
if (metricsConfig.token && !hasMetricsToken(req, metricsConfig.token)) {
|
|
392
|
+
headers.set("www-authenticate", 'Bearer realm="metrics"');
|
|
393
|
+
throw new InferloError("IL3001", "Sign in required.", { status: 401, fix: "Send the metrics token as Authorization: Bearer <token> (bearer_token in the Prometheus scrape config)." });
|
|
394
|
+
}
|
|
395
|
+
headers.set("content-type", "text/plain; version=0.0.4; charset=utf-8");
|
|
396
|
+
return new Response(method === "HEAD" ? null : metrics.render(), { status: 200, headers });
|
|
397
|
+
}
|
|
398
|
+
if (refusing) {
|
|
399
|
+
headers.set("connection", "close");
|
|
400
|
+
throw new InferloError("IL2006", "The server is shutting down.", { status: 503, fix: "Retry the request; another instance will answer it." });
|
|
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
|
+
}
|
|
406
|
+
if (openapiPath && url.pathname === openapiPath && method === "GET") {
|
|
407
|
+
seen.route = openapiPath;
|
|
233
408
|
return json(app.openapi(), 200, headers);
|
|
234
|
-
|
|
235
|
-
|
|
409
|
+
}
|
|
410
|
+
if (mcpPath && url.pathname === mcpPath) {
|
|
411
|
+
seen.route = mcpPath;
|
|
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);
|
|
429
|
+
}
|
|
236
430
|
const lookup = method === "HEAD" ? "GET" : method;
|
|
237
431
|
let match;
|
|
238
432
|
const allowed = [];
|
|
@@ -255,131 +449,322 @@ export function createApp(config = {}) {
|
|
|
255
449
|
throw new InferloError("IL2001", `No route matches ${method} ${url.pathname}.${near ? ` Closest route: ${near}.` : ""}`, { status: 404 });
|
|
256
450
|
}
|
|
257
451
|
const { def, keys } = match.route;
|
|
452
|
+
seen.route = def.path;
|
|
453
|
+
// Deprecation headers go on every answer of the route, errors included.
|
|
454
|
+
for (const [name, value] of deprecations.get(def)?.headers ?? [])
|
|
455
|
+
headers.set(name, value);
|
|
258
456
|
const opts = def.options;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
457
|
+
const limit = opts.timeoutMs === undefined ? requestTimeoutMs : opts.timeoutMs;
|
|
458
|
+
// timeout aborts when the route takes too long; signal (ctx.signal) also when the client disconnects.
|
|
459
|
+
const timeout = new AbortController();
|
|
460
|
+
const signal = AbortSignal.any([req.signal, timeout.signal]);
|
|
461
|
+
let requestDb;
|
|
462
|
+
const run = async () => {
|
|
463
|
+
// Auth
|
|
464
|
+
let user = null;
|
|
465
|
+
if (testUsers.has(req))
|
|
466
|
+
user = testUsers.get(req) ?? null;
|
|
467
|
+
else if (config.auth)
|
|
468
|
+
user = (await config.auth.resolve(req, { db: db, url })) ?? null;
|
|
469
|
+
const level = opts.auth ?? "user";
|
|
470
|
+
if (level !== "public") {
|
|
471
|
+
if (!user)
|
|
472
|
+
throw new InferloError("IL3001", "Sign in required.", { status: 401, fix: "Send valid credentials, e.g. an Authorization: Bearer <token> header." });
|
|
473
|
+
if (level !== "user" && !roles(user).includes(level))
|
|
474
|
+
throw new InferloError("IL3002", `This requires the "${level}" role.`, { status: 403 });
|
|
475
|
+
}
|
|
476
|
+
if (opts.permission)
|
|
477
|
+
await config.permissions.authorize(opts.permission, { user });
|
|
478
|
+
// Rate limit: counted per user (or IP without one), after auth so failed sign-ins on private routes do not use it up.
|
|
479
|
+
if (opts.rateLimit) {
|
|
480
|
+
const who = (opts.rateLimit.by ?? "user") === "user" && user ? `user:${user.id}` : `ip:${ip}`;
|
|
481
|
+
const hit = await rateLimits.hit(`${def.method} ${def.path} ${who}`, windowMs(opts.rateLimit));
|
|
482
|
+
const limited = rateLimitResult(opts.rateLimit, hit);
|
|
483
|
+
for (const [k, v] of Object.entries(limited.headers))
|
|
484
|
+
headers.set(k, v);
|
|
485
|
+
if (limited.error)
|
|
486
|
+
throw limited.error;
|
|
487
|
+
}
|
|
488
|
+
// Input
|
|
489
|
+
const rawParams = {};
|
|
490
|
+
keys.forEach((key, i) => {
|
|
491
|
+
const v = match.values[i];
|
|
492
|
+
if (v !== undefined)
|
|
493
|
+
rawParams[key] = decodeURIComponent(v);
|
|
494
|
+
});
|
|
495
|
+
const params = opts.params ? parseOrThrow(opts.params, rawParams, "path parameters", true) : rawParams;
|
|
496
|
+
const rawQuery = queryObject(url);
|
|
497
|
+
const query = opts.query ? parseOrThrow(opts.query.strip(), rawQuery, "query string", true) : rawQuery;
|
|
498
|
+
let body;
|
|
499
|
+
if (opts.body) {
|
|
500
|
+
const text = method === "GET" || method === "HEAD" ? "" : await readBody(req, maxBody, timeout.signal);
|
|
501
|
+
const type = req.headers.get("content-type") ?? "";
|
|
502
|
+
let raw = undefined;
|
|
503
|
+
if (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);
|
|
303
509
|
}
|
|
304
|
-
|
|
305
|
-
|
|
510
|
+
else {
|
|
511
|
+
try {
|
|
512
|
+
raw = JSON.parse(text);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
throw new InferloError("IL2003", "Request body is not valid JSON.", { status: 400, fix: "Send JSON with Content-Type: application/json." });
|
|
516
|
+
}
|
|
306
517
|
}
|
|
307
518
|
}
|
|
519
|
+
body = parseOrThrow(opts.body, raw, "request body", type.includes("application/x-www-form-urlencoded"));
|
|
308
520
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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 = [];
|
|
549
|
+
let status = 0;
|
|
550
|
+
const ctx = {
|
|
551
|
+
body,
|
|
552
|
+
query,
|
|
553
|
+
params,
|
|
554
|
+
user,
|
|
555
|
+
get db() {
|
|
556
|
+
// Built on first use: after a timeout, new queries fail with IL2007 instead of running.
|
|
557
|
+
return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal)));
|
|
558
|
+
},
|
|
559
|
+
ai: toolsFor(req, signal),
|
|
560
|
+
signal,
|
|
561
|
+
requestId,
|
|
562
|
+
jobs,
|
|
563
|
+
cache,
|
|
564
|
+
req,
|
|
565
|
+
url,
|
|
566
|
+
headers: req.headers,
|
|
567
|
+
state,
|
|
568
|
+
status: (code) => void (status = code),
|
|
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
|
+
},
|
|
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 }),
|
|
579
|
+
};
|
|
580
|
+
const result = await def.handler(ctx);
|
|
581
|
+
if (result instanceof Response) {
|
|
582
|
+
headers.forEach((v, k) => {
|
|
583
|
+
if (k !== "set-cookie" && !result.headers.has(k))
|
|
584
|
+
result.headers.set(k, v);
|
|
585
|
+
});
|
|
586
|
+
for (const cookie of headers.getSetCookie())
|
|
587
|
+
result.headers.append("set-cookie", cookie);
|
|
588
|
+
return result;
|
|
589
|
+
}
|
|
590
|
+
if (result === undefined)
|
|
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
|
+
}
|
|
600
|
+
const response = json(result, status || 200, headers);
|
|
601
|
+
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
327
602
|
};
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
603
|
+
if (limit === false)
|
|
604
|
+
return await run();
|
|
605
|
+
let timer;
|
|
606
|
+
const running = run();
|
|
607
|
+
// After a timeout the handler keeps running; its later result or error is dropped.
|
|
608
|
+
running.catch(() => { });
|
|
609
|
+
try {
|
|
610
|
+
return await Promise.race([
|
|
611
|
+
running,
|
|
612
|
+
new Promise((_, reject) => {
|
|
613
|
+
timer = setTimeout(() => {
|
|
614
|
+
const error = new InferloError("IL2007", `The request took longer than ${limit} ms.`, {
|
|
615
|
+
status: 503,
|
|
616
|
+
expose: true,
|
|
617
|
+
fix: `Make the handler faster or move slow work to a background job; raise the route's timeoutMs or createApp({ requestTimeoutMs }) if it needs more time.`,
|
|
618
|
+
});
|
|
619
|
+
timeout.abort(error);
|
|
620
|
+
reject(error);
|
|
621
|
+
}, limit);
|
|
622
|
+
}),
|
|
623
|
+
]);
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
clearTimeout(timer);
|
|
335
627
|
}
|
|
336
|
-
if (result === undefined)
|
|
337
|
-
return new Response(null, { status: status || 204, headers });
|
|
338
|
-
const response = json(result, status || 200, headers);
|
|
339
|
-
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
340
628
|
}
|
|
341
629
|
catch (error) {
|
|
342
|
-
return errorResponse(error, headers, req);
|
|
630
|
+
return errorResponse(error, headers, req, requestId);
|
|
343
631
|
}
|
|
344
632
|
};
|
|
633
|
+
// Shutdown state: closing makes /ready answer 503, refusing (after delayMs) answers new requests with 503.
|
|
634
|
+
let closing = false;
|
|
635
|
+
let refusing = false;
|
|
636
|
+
let closed;
|
|
637
|
+
let inFlight = 0;
|
|
638
|
+
const tasks = new Set();
|
|
639
|
+
const servers = new Set();
|
|
640
|
+
let idle = [];
|
|
641
|
+
const settle = () => {
|
|
642
|
+
if (inFlight > 0 || tasks.size > 0)
|
|
643
|
+
return;
|
|
644
|
+
for (const resolve of idle)
|
|
645
|
+
resolve();
|
|
646
|
+
idle = [];
|
|
647
|
+
};
|
|
648
|
+
const whenIdle = () => new Promise((resolve) => {
|
|
649
|
+
idle.push(resolve);
|
|
650
|
+
settle();
|
|
651
|
+
});
|
|
652
|
+
/** Resolves true when promise settled before ms passed, false otherwise. */
|
|
653
|
+
const within = async (promise, ms) => {
|
|
654
|
+
let timer;
|
|
655
|
+
try {
|
|
656
|
+
return await Promise.race([promise.then(() => true, () => true), new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms)))]);
|
|
657
|
+
}
|
|
658
|
+
finally {
|
|
659
|
+
clearTimeout(timer);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
async function handle(req, rest) {
|
|
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();
|
|
686
|
+
// Behind a trusted proxy, keep the id it sent so log entries line up across services.
|
|
687
|
+
const incoming = config.trustProxy ? req.headers.get("x-request-id") : null;
|
|
688
|
+
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
689
|
+
const ip = clientIp(req, Boolean(config.trustProxy), rest[0]);
|
|
690
|
+
if (ip !== "unknown")
|
|
691
|
+
clientIps.set(req, ip);
|
|
692
|
+
const state = {};
|
|
693
|
+
const pending = [];
|
|
694
|
+
const chain = [...(config.middleware ?? [])];
|
|
695
|
+
// The matched route pattern, for metrics ("unmatched" for 404s and responses from middleware).
|
|
696
|
+
const seen = { route: "unmatched" };
|
|
697
|
+
const run = (i) => {
|
|
698
|
+
const mw = chain[i];
|
|
699
|
+
if (!mw)
|
|
700
|
+
return dispatch(req, url, state, pending, ip, requestId, seen);
|
|
701
|
+
return mw({ req, url, state, requestId }, () => run(i + 1));
|
|
702
|
+
};
|
|
703
|
+
let response;
|
|
704
|
+
try {
|
|
705
|
+
response = await run(0);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
response = errorResponse(error, new Headers(), req, requestId);
|
|
709
|
+
}
|
|
710
|
+
response = withHeader(response, "x-request-id", requestId);
|
|
711
|
+
if (span && seen.route !== "unmatched") {
|
|
712
|
+
span.updateName(`${req.method.toUpperCase()} ${seen.route}`);
|
|
713
|
+
span.setAttribute("http.route", seen.route);
|
|
714
|
+
}
|
|
715
|
+
if (!probe) {
|
|
716
|
+
const elapsed = performance.now() - started;
|
|
717
|
+
const method = req.method.toUpperCase();
|
|
718
|
+
metrics?.request(method, seen.route, response.status, elapsed / 1000);
|
|
719
|
+
if (logging.requests) {
|
|
720
|
+
const durationMs = Math.round(elapsed * 10) / 10;
|
|
721
|
+
logger.info(`${method} ${url.pathname} ${response.status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status: response.status, durationMs });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
if (secureHeaders.length) {
|
|
725
|
+
const https = url.protocol === "https:" || (Boolean(config.trustProxy) && req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https");
|
|
726
|
+
response = applySecureHeaders(response, secureHeaders, https);
|
|
727
|
+
}
|
|
728
|
+
if (pending.length) {
|
|
729
|
+
const all = Promise.all(pending);
|
|
730
|
+
const execution = rest[1];
|
|
731
|
+
if (execution?.waitUntil)
|
|
732
|
+
execution.waitUntil(all);
|
|
733
|
+
else {
|
|
734
|
+
// Kept until it settles, so app.close() waits for it.
|
|
735
|
+
tasks.add(all);
|
|
736
|
+
void all.finally(() => {
|
|
737
|
+
tasks.delete(all);
|
|
738
|
+
settle();
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return response;
|
|
743
|
+
}
|
|
345
744
|
const app = {
|
|
346
745
|
name: config.name ?? "inferlo-app",
|
|
347
746
|
version: config.version ?? "0.0.0",
|
|
348
747
|
routes,
|
|
349
748
|
models,
|
|
749
|
+
modules,
|
|
350
750
|
db,
|
|
351
751
|
ai,
|
|
352
752
|
jobs,
|
|
753
|
+
cache,
|
|
754
|
+
get closing() {
|
|
755
|
+
return closing;
|
|
756
|
+
},
|
|
353
757
|
async fetch(req, ...rest) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (ip !== "unknown")
|
|
357
|
-
clientIps.set(req, ip);
|
|
358
|
-
const state = {};
|
|
359
|
-
const pending = [];
|
|
360
|
-
const chain = [...(config.middleware ?? [])];
|
|
361
|
-
const run = (i) => {
|
|
362
|
-
const mw = chain[i];
|
|
363
|
-
if (!mw)
|
|
364
|
-
return dispatch(req, url, state, pending, ip);
|
|
365
|
-
return mw({ req, url, state }, () => run(i + 1));
|
|
366
|
-
};
|
|
367
|
-
let response;
|
|
758
|
+
inFlight++;
|
|
759
|
+
metrics?.inFlight(1);
|
|
368
760
|
try {
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
catch (error) {
|
|
372
|
-
response = errorResponse(error, new Headers(), req);
|
|
761
|
+
return await handle(req, rest);
|
|
373
762
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
execution.waitUntil(all);
|
|
379
|
-
else
|
|
380
|
-
void all;
|
|
763
|
+
finally {
|
|
764
|
+
inFlight--;
|
|
765
|
+
metrics?.inFlight(-1);
|
|
766
|
+
settle();
|
|
381
767
|
}
|
|
382
|
-
return response;
|
|
383
768
|
},
|
|
384
769
|
async request(path, options = {}) {
|
|
385
770
|
const url = new URL(path, "http://inferlo.test");
|
|
@@ -399,17 +784,143 @@ export function createApp(config = {}) {
|
|
|
399
784
|
},
|
|
400
785
|
async listen(port = 3000, hostname = "0.0.0.0") {
|
|
401
786
|
const { serve } = await import("./serve.js");
|
|
402
|
-
|
|
787
|
+
const server = await serve(app, { port, hostname });
|
|
788
|
+
servers.add(server);
|
|
789
|
+
return {
|
|
790
|
+
port: server.port,
|
|
791
|
+
close: async () => {
|
|
792
|
+
servers.delete(server);
|
|
793
|
+
await server.close();
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
},
|
|
797
|
+
async checkReady() {
|
|
798
|
+
if (closing)
|
|
799
|
+
return { status: "not_ready", checks: { shutdown: { status: "error", durationMs: 0, error: "The app is shutting down." } } };
|
|
800
|
+
const names = Object.keys(readinessChecks);
|
|
801
|
+
const results = await Promise.all(names.map((name) => runCheck(name, readinessChecks[name])));
|
|
802
|
+
const checks = {};
|
|
803
|
+
names.forEach((name, i) => (checks[name] = results[i]));
|
|
804
|
+
return { status: results.every((r) => r.status === "ok") ? "ready" : "not_ready", checks };
|
|
805
|
+
},
|
|
806
|
+
close(options = {}) {
|
|
807
|
+
if (closed)
|
|
808
|
+
return closed;
|
|
809
|
+
const delayMs = options.delayMs ?? 0;
|
|
810
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
811
|
+
for (const [key, value] of [["delayMs", delayMs], ["timeoutMs", timeoutMs]]) {
|
|
812
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
813
|
+
throw new InferloError("IL6015", `app.close({ ${key} }) must be a number of milliseconds of 0 or more, got ${String(value)}.`, {
|
|
814
|
+
fix: "Use app.close({ delayMs: 5000, timeoutMs: 10000 }) or app.close().",
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
closing = true;
|
|
819
|
+
closed = (async () => {
|
|
820
|
+
if (delayMs > 0)
|
|
821
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
822
|
+
refusing = true;
|
|
823
|
+
const deadline = Date.now() + timeoutMs;
|
|
824
|
+
let workersStopped = false;
|
|
825
|
+
const drained = Promise.all([whenIdle(), stopWorkers(jobs).then(() => void (workersStopped = true))]);
|
|
826
|
+
if (!(await within(drained, timeoutMs))) {
|
|
827
|
+
const left = [`${inFlight} request(s)`, `${tasks.size} waitUntil task(s)`];
|
|
828
|
+
if (!workersStopped)
|
|
829
|
+
left.push("the running job");
|
|
830
|
+
logger.error(`app.close(): stopped waiting after ${timeoutMs} ms with ${left.join(", ")} still running`);
|
|
831
|
+
}
|
|
832
|
+
const { forceClosers } = servers.size ? await import("./serve.js") : { forceClosers: undefined };
|
|
833
|
+
await Promise.all([...servers].map(async (server) => {
|
|
834
|
+
servers.delete(server);
|
|
835
|
+
if (!(await within(server.close(), Math.max(0, deadline - Date.now()))))
|
|
836
|
+
forceClosers?.get(server)?.();
|
|
837
|
+
}));
|
|
838
|
+
await db.$close();
|
|
839
|
+
})();
|
|
840
|
+
return closed;
|
|
403
841
|
},
|
|
404
842
|
describe() {
|
|
405
|
-
return describeApp(app, {
|
|
843
|
+
return describeApp(app, {
|
|
844
|
+
auth: Boolean(config.auth),
|
|
845
|
+
openapi: openapiPath,
|
|
846
|
+
mcp: mcpPath,
|
|
847
|
+
health: health?.path ?? false,
|
|
848
|
+
ready: health?.readyPath ?? false,
|
|
849
|
+
metrics: metricsConfig?.path ?? false,
|
|
850
|
+
secureHeaders: secureHeaders.map(([name]) => name),
|
|
851
|
+
requestTimeoutMs,
|
|
852
|
+
logging: { format: config.logger ? "custom" : logging.format, requests: logging.requests },
|
|
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 })) ?? [],
|
|
857
|
+
});
|
|
406
858
|
},
|
|
407
|
-
openapi() {
|
|
408
|
-
|
|
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 });
|
|
409
866
|
},
|
|
410
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
|
+
});
|
|
411
882
|
return app;
|
|
412
883
|
}
|
|
884
|
+
function resolveHealth(option, routes, taken) {
|
|
885
|
+
if (option === undefined || option === false)
|
|
886
|
+
return undefined;
|
|
887
|
+
const bad = (message, fix) => new InferloError("IL6014", message, { fix });
|
|
888
|
+
if (option !== true && (typeof option !== "object" || option === null))
|
|
889
|
+
throw bad("health must be true or an object.", "Use createApp({ health: true }) or createApp({ health: { checks: { ... } } }).");
|
|
890
|
+
const o = option === true ? {} : option;
|
|
891
|
+
const resolved = { path: o.path ?? "/health", readyPath: o.readyPath ?? "/ready", timeoutMs: o.timeoutMs ?? 5000, checks: o.checks ?? {} };
|
|
892
|
+
for (const [key, value] of [["path", resolved.path], ["readyPath", resolved.readyPath]]) {
|
|
893
|
+
if (typeof value !== "string" || !value.startsWith("/"))
|
|
894
|
+
throw bad(`health.${key} must be a path starting with "/", got ${JSON.stringify(value)}.`, `Use health: { ${key}: "/${String(value).replace(/^\/+/, "")}" }.`);
|
|
895
|
+
}
|
|
896
|
+
if (resolved.path === resolved.readyPath)
|
|
897
|
+
throw bad(`health.path and health.readyPath are both "${resolved.path}".`, 'Use different paths, for example "/health" and "/ready".');
|
|
898
|
+
if (!Number.isFinite(resolved.timeoutMs) || resolved.timeoutMs <= 0)
|
|
899
|
+
throw bad(`health.timeoutMs must be a positive number of milliseconds, got ${String(resolved.timeoutMs)}.`, "Use health: { timeoutMs: 5000 }.");
|
|
900
|
+
if (typeof resolved.checks !== "object" || resolved.checks === null)
|
|
901
|
+
throw bad("health.checks must be an object of functions.", "Use health: { checks: { redis: () => redis.ping() } }.");
|
|
902
|
+
for (const [name, check] of Object.entries(resolved.checks)) {
|
|
903
|
+
if (name === "database" || name === "ai")
|
|
904
|
+
throw bad(`health.checks.${name} is built in.`, `Give your check another name, for example "${name}Replica".`);
|
|
905
|
+
if (typeof check !== "function")
|
|
906
|
+
throw bad(`health.checks.${name} must be a function.`, `Use health: { checks: { ${name}: async () => { ... } } }; throw when it is not ready.`);
|
|
907
|
+
}
|
|
908
|
+
for (const path of [resolved.path, resolved.readyPath]) {
|
|
909
|
+
if (taken.includes(path))
|
|
910
|
+
throw bad(`The health endpoint ${path} is also the OpenAPI or MCP path.`, "Choose another health.path or health.readyPath.");
|
|
911
|
+
const route = routes.find((r) => r.method === "GET" && r.path.replace(/\/+$/, "") === path.replace(/\/+$/, ""));
|
|
912
|
+
if (route)
|
|
913
|
+
throw bad(`The health endpoint ${path} is also the route GET ${route.path}.`, `Remove the GET ${route.path} route (createApp({ health }) answers it now), or choose another health.path or health.readyPath.`);
|
|
914
|
+
}
|
|
915
|
+
return resolved;
|
|
916
|
+
}
|
|
917
|
+
function checkTimeout(value, where) {
|
|
918
|
+
if (value === false || (typeof value === "number" && Number.isFinite(value) && value > 0))
|
|
919
|
+
return value;
|
|
920
|
+
throw new InferloError("IL6017", `${where} must be a positive number of milliseconds or false, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`, {
|
|
921
|
+
fix: "Use a number such as 30000 (30 seconds), or false for no timeout.",
|
|
922
|
+
});
|
|
923
|
+
}
|
|
413
924
|
function parseOrThrow(schema, value, where, coerce) {
|
|
414
925
|
const result = schema.safeParse(value, { coerce });
|
|
415
926
|
if (!result.ok)
|