@browserlessapi/inferlo 0.5.0 → 0.7.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 +14 -5
- package/dist/ai/ai-sdk.d.ts +48 -0
- package/dist/ai/ai-sdk.js +235 -0
- package/dist/ai/ai-sdk.js.map +1 -0
- package/dist/ai/index.d.ts +44 -3
- package/dist/ai/index.js +164 -9
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/public.d.ts +6 -1
- package/dist/ai/public.js +2 -0
- package/dist/ai/public.js.map +1 -1
- package/dist/ai/ui.d.ts +47 -0
- package/dist/ai/ui.js +33 -0
- package/dist/ai/ui.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 +60 -6
- package/dist/app.js +310 -66
- package/dist/app.js.map +1 -1
- package/dist/astro.d.ts +60 -0
- package/dist/astro.js +68 -0
- package/dist/astro.js.map +1 -0
- package/dist/audit.d.ts +57 -0
- package/dist/audit.js +99 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/azure.d.ts +66 -0
- package/dist/azure.js +119 -0
- package/dist/azure.js.map +1 -0
- package/dist/cache.d.ts +96 -0
- package/dist/cache.js +247 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli/main.js +28 -7
- package/dist/cli/main.js.map +1 -1
- package/dist/client-ip.d.ts +36 -1
- package/dist/client-ip.js +80 -1
- package/dist/client-ip.js.map +1 -1
- package/dist/db/memory.js +2 -1
- package/dist/db/memory.js.map +1 -1
- package/dist/db/postgres.js +4 -1
- package/dist/db/postgres.js.map +1 -1
- package/dist/db/table.d.ts +37 -5
- package/dist/db/table.js +96 -15
- package/dist/db/table.js.map +1 -1
- package/dist/db/types.d.ts +2 -0
- package/dist/describe.d.ts +54 -2
- package/dist/describe.js +34 -3
- package/dist/describe.js.map +1 -1
- package/dist/events.d.ts +134 -0
- package/dist/events.js +196 -0
- package/dist/events.js.map +1 -0
- 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 +25 -5
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +70 -10
- package/dist/jobs.js +78 -13
- 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/langchain.d.ts +58 -0
- package/dist/langchain.js +220 -0
- package/dist/langchain.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/model.d.ts +23 -6
- package/dist/model.js +35 -6
- package/dist/model.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/rate-limit.d.ts +5 -3
- package/dist/rate-limit.js +3 -3
- package/dist/rate-limit.js.map +1 -1
- 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/redis-events.d.ts +27 -0
- package/dist/redis-events.js +163 -0
- package/dist/redis-events.js.map +1 -0
- package/dist/redis-jobs.d.ts +24 -0
- package/dist/redis-jobs.js +203 -0
- package/dist/redis-jobs.js.map +1 -0
- package/dist/redis.d.ts +30 -0
- package/dist/redis.js +106 -0
- package/dist/redis.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/response-body.d.ts +6 -0
- package/dist/response-body.js +7 -0
- package/dist/response-body.js.map +1 -0
- package/dist/route.d.ts +50 -2
- package/dist/route.js +24 -0
- package/dist/route.js.map +1 -1
- package/dist/schema.d.ts +2 -2
- package/dist/serve.d.ts +26 -2
- package/dist/serve.js +111 -10
- package/dist/serve.js.map +1 -1
- package/dist/service-client.d.ts +91 -0
- package/dist/service-client.js +177 -0
- package/dist/service-client.js.map +1 -0
- package/dist/sessions.d.ts +74 -0
- package/dist/sessions.js +172 -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 +122 -0
- package/dist/tracing.js +210 -0
- package/dist/tracing.js.map +1 -0
- package/llms.txt +8 -3
- package/package.json +78 -4
- package/templates/app/AGENTS.md +10 -6
package/dist/app.js
CHANGED
|
@@ -4,21 +4,33 @@
|
|
|
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, checkTenantId, 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";
|
|
15
|
+
import { checkRedisClient, pingRedis, redisCacheStore, redisMissing, redisPrefix, redisRateLimitStore } from "./redis.js";
|
|
13
16
|
import { checkRateLimit, databaseRateLimitStore, memoryRateLimitStore, rateLimitResult, windowMs } from "./rate-limit.js";
|
|
14
|
-
import { clientIp, clientIps } from "./client-ip.js";
|
|
15
|
-
import {
|
|
17
|
+
import { clientIp, clientIps, clientSignals, keepEventStreamOpen, platformWaitUntil } from "./client-ip.js";
|
|
18
|
+
import { redisJobStore } from "./redis-jobs.js";
|
|
19
|
+
import { knownBodies } from "./response-body.js";
|
|
20
|
+
import { collectCrons, collectJobs, collectSubscriptions, createJobs, jobStoreFor, stopWorkers, withTenant } from "./jobs.js";
|
|
21
|
+
import { checkEventsConfig, invalidTransport } from "./events.js";
|
|
22
|
+
import { redisEventTransport } from "./redis-events.js";
|
|
16
23
|
import { ValidationError } from "./schema.js";
|
|
17
24
|
import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
|
|
18
25
|
import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
|
|
26
|
+
import { clientTracing, jobTracing, requestTracing, traceAdapter, traceFields } from "./tracing.js";
|
|
19
27
|
import { resolveDeprecation } from "./deprecation.js";
|
|
20
28
|
import { createMetrics, hasMetricsToken, resolveMetrics } from "./metrics.js";
|
|
21
29
|
import { INFERLO_VERSION } from "./describe.js";
|
|
30
|
+
import { registerRequestMatcher } from "./request-matcher.js";
|
|
31
|
+
import { versionDocPath, versionsOf } from "./api-version.js";
|
|
32
|
+
import { resolveModules } from "./module.js";
|
|
33
|
+
import { isPermissions } from "./permissions.js";
|
|
22
34
|
import { applySecureHeaders, resolveSecureHeaders } from "./secure-headers.js";
|
|
23
35
|
const testUsers = new WeakMap();
|
|
24
36
|
function compile(def) {
|
|
@@ -45,29 +57,6 @@ function compile(def) {
|
|
|
45
57
|
.join("");
|
|
46
58
|
return { def, regex: new RegExp(`^${pattern || ""}/?$`), keys, score: score * 10 + parts.length };
|
|
47
59
|
}
|
|
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
60
|
function corsHeaders(config, origin) {
|
|
72
61
|
const allowed = config.origin === "*" ? "*" : origin && (Array.isArray(config.origin) ? config.origin : [config.origin]).includes(origin) ? origin : null;
|
|
73
62
|
if (!allowed)
|
|
@@ -131,14 +120,22 @@ function withHeader(response, name, value) {
|
|
|
131
120
|
}
|
|
132
121
|
function json(data, status, headers) {
|
|
133
122
|
headers.set("content-type", "application/json; charset=utf-8");
|
|
134
|
-
|
|
123
|
+
const text = JSON.stringify(data);
|
|
124
|
+
const response = new Response(text, { status, headers });
|
|
125
|
+
knownBodies.set(response, text);
|
|
126
|
+
return response;
|
|
135
127
|
}
|
|
136
128
|
export function createApp(config = {}) {
|
|
137
129
|
// Everything the app logs goes through the redactor: credentials and secrets never reach the logs.
|
|
138
130
|
const redactor = createRedactor([...redactValues(config.redact), ...Object.values(config.ai?.providers ?? {}).flatMap((p) => secretsOf(p))]);
|
|
139
131
|
const logging = loggingOptions(config.logging, config.logger !== undefined);
|
|
140
132
|
const logger = redactingLogger(config.logger ?? defaultLogger(logging.format, config.name ?? "inferlo-app"), redactor);
|
|
141
|
-
const
|
|
133
|
+
const tracing = requestTracing(config.tracing, config.name ?? "inferlo-app", config.version ?? "0.0.0");
|
|
134
|
+
const tracingApi = tracing ? config.tracing.api : undefined;
|
|
135
|
+
// The server span of each request, for errors recorded while it runs.
|
|
136
|
+
const requestSpans = new WeakMap();
|
|
137
|
+
const { modules, modelOwner } = resolveModules(config.modules, config.models ?? []);
|
|
138
|
+
const models = [...(config.models ?? []), ...modules.flatMap((m) => m.models)];
|
|
142
139
|
const modelNames = new Set();
|
|
143
140
|
for (const m of models) {
|
|
144
141
|
if (modelNames.has(m.name))
|
|
@@ -155,7 +152,15 @@ export function createApp(config = {}) {
|
|
|
155
152
|
}
|
|
156
153
|
}
|
|
157
154
|
}
|
|
155
|
+
// Routes of the app itself, then each module's; routeModule remembers which module a route came from.
|
|
156
|
+
const routeModule = new Map();
|
|
158
157
|
const routes = collectRoutes(config.routes);
|
|
158
|
+
for (const m of modules) {
|
|
159
|
+
for (const r of collectRoutes(m.routes)) {
|
|
160
|
+
routeModule.set(r, m.name);
|
|
161
|
+
routes.push(r);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
159
164
|
const compiled = routes.map(compile).sort((a, b) => b.score - a.score);
|
|
160
165
|
const signatures = new Map();
|
|
161
166
|
for (const r of routes) {
|
|
@@ -180,9 +185,29 @@ export function createApp(config = {}) {
|
|
|
180
185
|
fix: 'Routes are private by default. Add auth: "public" to routes anyone may call, or pass createApp({ auth: { resolve(req) { ... } } }).',
|
|
181
186
|
});
|
|
182
187
|
}
|
|
188
|
+
if (config.permissions !== undefined && !isPermissions(config.permissions)) {
|
|
189
|
+
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.' });
|
|
190
|
+
}
|
|
191
|
+
for (const r of routes) {
|
|
192
|
+
const name = r.options.permission;
|
|
193
|
+
if (name === undefined)
|
|
194
|
+
continue;
|
|
195
|
+
const where = `${r.method} ${r.path}`;
|
|
196
|
+
if (typeof name !== "string" || !config.permissions?.has(name)) {
|
|
197
|
+
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.`, {
|
|
198
|
+
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({ ... }).",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (r.options.auth === "public") {
|
|
202
|
+
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.' });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
183
205
|
for (const r of routes)
|
|
184
206
|
if (r.options.rateLimit !== undefined)
|
|
185
207
|
checkRateLimit(r.options.rateLimit, `${r.method} ${r.path}`);
|
|
208
|
+
for (const r of routes)
|
|
209
|
+
if (r.options.cache !== undefined)
|
|
210
|
+
checkRouteCache(r.options.cache, r.method, `${r.method} ${r.path}`);
|
|
186
211
|
const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
|
|
187
212
|
for (const r of routes)
|
|
188
213
|
if (r.options.timeoutMs !== undefined)
|
|
@@ -194,39 +219,90 @@ export function createApp(config = {}) {
|
|
|
194
219
|
deprecations.set(r, resolved);
|
|
195
220
|
}
|
|
196
221
|
const storeName = config.rateLimit?.store ?? "memory";
|
|
197
|
-
if (storeName !== "memory" && storeName !== "database") {
|
|
198
|
-
throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }) or { store: "
|
|
222
|
+
if (storeName !== "memory" && storeName !== "database" && storeName !== "redis") {
|
|
223
|
+
throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }), { store: "database" } or { store: "redis" }.' });
|
|
199
224
|
}
|
|
225
|
+
const redis = checkRedisClient(config.redis);
|
|
226
|
+
if (storeName === "redis" && !redis)
|
|
227
|
+
throw redisMissing("rateLimit");
|
|
200
228
|
const openapiPath = config.openapi === undefined ? "/openapi.json" : config.openapi;
|
|
201
229
|
const hasTools = routes.some((r) => r.options.tool);
|
|
202
230
|
const mcpPath = config.mcp === undefined ? (hasTools ? "/mcp" : false) : config.mcp;
|
|
203
|
-
const health = resolveHealth(config.health, routes, [openapiPath, mcpPath]);
|
|
231
|
+
const health = resolveHealth(config.health, routes, [openapiPath, mcpPath], redis ? ["redis"] : []);
|
|
232
|
+
// apiVersion("v1", ...) routes get their own OpenAPI document at /v1/openapi.json.
|
|
233
|
+
const versions = versionsOf(routes);
|
|
234
|
+
const versionDocs = new Map(openapiPath ? versions.map((v) => [versionDocPath(v, openapiPath), v]) : []);
|
|
235
|
+
for (const docPath of versionDocs.keys()) {
|
|
236
|
+
const clash = routes.find((r) => r.method === "GET" && r.path.replace(/\/+$/, "") === docPath);
|
|
237
|
+
if (clash)
|
|
238
|
+
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." });
|
|
239
|
+
}
|
|
204
240
|
const metricsConfig = resolveMetrics(config.metrics, [
|
|
241
|
+
...[...versionDocs.keys()].map((path) => ({ path, what: "an OpenAPI document path" })),
|
|
205
242
|
{ path: openapiPath, what: "the OpenAPI path" },
|
|
206
243
|
{ path: mcpPath, what: "the MCP path" },
|
|
207
244
|
{ path: health?.path ?? false, what: "the health path" },
|
|
208
245
|
{ path: health?.readyPath ?? false, what: "the readiness path" },
|
|
209
246
|
...routes.filter((r) => r.method === "GET").map((r) => ({ path: r.path, what: `the route GET ${r.path}` })),
|
|
210
247
|
]);
|
|
211
|
-
|
|
248
|
+
// With tracing, database queries made while a request or job is traced get client spans.
|
|
249
|
+
const appTracing = tracing ? { ...config.tracing, tracerName: config.tracing.tracerName ?? config.name ?? "inferlo-app" } : undefined;
|
|
250
|
+
const adapter = appTracing ? traceAdapter(config.db ?? memory(), clientTracing(appTracing, "inferlo-app", config.version ?? "0.0.0")) : (config.db ?? memory());
|
|
212
251
|
const db = createDb(models, adapter);
|
|
213
|
-
const rateLimits = storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
|
|
252
|
+
const rateLimits = storeName === "redis" ? redisRateLimitStore(redis, redisPrefix(config.name)) : storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
|
|
253
|
+
const cacheConfig = resolveCacheConfig(config.cache);
|
|
254
|
+
if (cacheConfig.store === "redis" && !redis)
|
|
255
|
+
throw redisMissing("cache");
|
|
256
|
+
const cacheStore = cacheConfig.store === "redis" ? redisCacheStore(redis, redisPrefix(config.name)) : cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries);
|
|
257
|
+
const cache = createCache(cacheStore, cacheConfig.store);
|
|
214
258
|
const metrics = metricsConfig
|
|
215
259
|
? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
|
|
216
260
|
: undefined;
|
|
217
261
|
// With metrics on, every AI call that reports usage is counted before the app's own onUsage runs.
|
|
218
|
-
|
|
219
|
-
|
|
262
|
+
// With tracing, AI calls get client spans too (unless the AI config has its own tracing).
|
|
263
|
+
const aiConfig = config.ai && (metrics || appTracing)
|
|
264
|
+
? {
|
|
265
|
+
...config.ai,
|
|
266
|
+
...(metrics ? { onUsage: (usage) => (metrics.ai(usage), config.ai.onUsage?.(usage)) } : {}),
|
|
267
|
+
...(appTracing ? { tracing: config.ai.tracing ?? appTracing } : {}),
|
|
268
|
+
}
|
|
220
269
|
: config.ai;
|
|
221
270
|
const ai = createAI(aiConfig);
|
|
222
|
-
const
|
|
271
|
+
const jobModule = new Map();
|
|
272
|
+
for (const m of modules)
|
|
273
|
+
for (const j of collectJobs(m.jobs))
|
|
274
|
+
jobModule.set(j.name, m.name);
|
|
275
|
+
const moduleInfo = { modules, routeModule, modelOwner, jobModule };
|
|
276
|
+
const jobSources = [...(config.jobs ?? []), ...modules.flatMap((m) => m.jobs)];
|
|
277
|
+
const transportOption = checkEventsConfig(config.events);
|
|
278
|
+
let eventTransport;
|
|
279
|
+
if (transportOption === "redis") {
|
|
280
|
+
if (!redis)
|
|
281
|
+
throw new InferloError("IL6031", 'events transport "redis" needs a Redis client, but createApp has no redis option.', { fix: 'Create and connect a client with the redis package: const redis = await createClient({ url: process.env.REDIS_URL }).connect(); then createApp({ name: "billing", redis, events: { transport: "redis" } }).' });
|
|
282
|
+
// The app name is the consumer group: two services with one name would split each other's events.
|
|
283
|
+
if (!config.name)
|
|
284
|
+
throw invalidTransport('events transport "redis" needs createApp({ name }): each service reads events as its own consumer group, named after the app.');
|
|
285
|
+
eventTransport = redisEventTransport(redis, { group: config.name });
|
|
286
|
+
}
|
|
287
|
+
else
|
|
288
|
+
eventTransport = transportOption;
|
|
289
|
+
const jobs = createJobs(collectJobs(jobSources), {
|
|
223
290
|
db: db,
|
|
224
291
|
ai,
|
|
225
|
-
|
|
292
|
+
cache,
|
|
293
|
+
audit: (run) => createAudit(models, redactor, () => db, { userId: null, tenantId: run.tenantId, source: `job ${run.name}` }),
|
|
294
|
+
store: jobStoreFor(config.jobQueue, adapter, () => {
|
|
295
|
+
if (!redis)
|
|
296
|
+
throw redisMissing("jobQueue");
|
|
297
|
+
return redisJobStore(redis, redisPrefix(config.name));
|
|
298
|
+
}),
|
|
226
299
|
queue: config.jobQueue?.store ?? "memory",
|
|
227
300
|
logger,
|
|
228
301
|
onRun: metrics ? (name, outcome, ms) => metrics.job(name, outcome, ms / 1000) : undefined,
|
|
229
|
-
|
|
302
|
+
tracing: appTracing ? jobTracing(appTracing, "inferlo-app", config.version ?? "0.0.0") : undefined,
|
|
303
|
+
eventTransport,
|
|
304
|
+
}, collectCrons(jobSources), collectSubscriptions(jobSources));
|
|
305
|
+
const events = jobs.events;
|
|
230
306
|
const maxBody = config.maxBodySize ?? 1024 * 1024;
|
|
231
307
|
const secureHeaders = resolveSecureHeaders(config.secureHeaders);
|
|
232
308
|
const cors = config.cors === true ? { origin: "*" } : config.cors || undefined;
|
|
@@ -250,6 +326,8 @@ export function createApp(config = {}) {
|
|
|
250
326
|
}
|
|
251
327
|
};
|
|
252
328
|
}
|
|
329
|
+
if (redis)
|
|
330
|
+
readinessChecks.redis = () => pingRedis(redis);
|
|
253
331
|
Object.assign(readinessChecks, health?.checks);
|
|
254
332
|
const runCheck = async (name, check) => {
|
|
255
333
|
const started = Date.now();
|
|
@@ -305,9 +383,19 @@ export function createApp(config = {}) {
|
|
|
305
383
|
}
|
|
306
384
|
return { name: tool.name, status: response.status, output, text: text || String(response.status) };
|
|
307
385
|
}, describeTool, signal);
|
|
386
|
+
const tenantScope = (user) => {
|
|
387
|
+
if (!user)
|
|
388
|
+
return "none";
|
|
389
|
+
const id = config.auth?.tenant ? config.auth.tenant(user) : user.tenantId;
|
|
390
|
+
return id === null || id === undefined ? "none" : { tenant: checkTenantId(id) };
|
|
391
|
+
};
|
|
392
|
+
const tenantOf = (scope) => (typeof scope === "object" ? scope.tenant : null);
|
|
308
393
|
const roles = (user) => config.auth?.roles?.(user) ?? (user.roles ?? []);
|
|
309
394
|
const errorResponse = (error, headers, req, requestId) => {
|
|
310
|
-
const
|
|
395
|
+
const span = requestSpans.get(req);
|
|
396
|
+
const fields = { requestId, ...traceFields(span) };
|
|
397
|
+
if (span && !(error instanceof InferloError && error.status < 500))
|
|
398
|
+
span.recordException(error);
|
|
311
399
|
if (error instanceof InferloError) {
|
|
312
400
|
if (error.status >= 500)
|
|
313
401
|
logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "", fields);
|
|
@@ -329,7 +417,7 @@ export function createApp(config = {}) {
|
|
|
329
417
|
const method = req.method.toUpperCase();
|
|
330
418
|
if (method === "OPTIONS" && cors) {
|
|
331
419
|
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(", "));
|
|
420
|
+
headers.set("access-control-allow-headers", (cors.headers ?? ["content-type", "authorization", "x-csrf-token"]).join(", "));
|
|
333
421
|
headers.set("access-control-max-age", String(cors.maxAge ?? 600));
|
|
334
422
|
return new Response(null, { status: 204, headers });
|
|
335
423
|
}
|
|
@@ -352,13 +440,33 @@ export function createApp(config = {}) {
|
|
|
352
440
|
headers.set("connection", "close");
|
|
353
441
|
throw new InferloError("IL2006", "The server is shutting down.", { status: 503, fix: "Retry the request; another instance will answer it." });
|
|
354
442
|
}
|
|
443
|
+
if (method === "GET" && versionDocs.has(url.pathname)) {
|
|
444
|
+
seen.route = url.pathname;
|
|
445
|
+
return json(app.openapi({ version: versionDocs.get(url.pathname) }), 200, headers);
|
|
446
|
+
}
|
|
355
447
|
if (openapiPath && url.pathname === openapiPath && method === "GET") {
|
|
356
448
|
seen.route = openapiPath;
|
|
357
449
|
return json(app.openapi(), 200, headers);
|
|
358
450
|
}
|
|
359
451
|
if (mcpPath && url.pathname === mcpPath) {
|
|
360
452
|
seen.route = mcpPath;
|
|
361
|
-
|
|
453
|
+
// tools/list leaves out tools that need a role or permission the caller does not have.
|
|
454
|
+
let caller;
|
|
455
|
+
const visible = async (def) => {
|
|
456
|
+
const level = def.options.auth ?? "user";
|
|
457
|
+
if (!def.options.permission && (level === "public" || level === "user"))
|
|
458
|
+
return true;
|
|
459
|
+
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);
|
|
460
|
+
const user = await caller;
|
|
461
|
+
if (!user)
|
|
462
|
+
return false;
|
|
463
|
+
if (level !== "public" && level !== "user" && !roles(user).includes(level))
|
|
464
|
+
return false;
|
|
465
|
+
if (!def.options.permission)
|
|
466
|
+
return true;
|
|
467
|
+
return config.permissions.can(def.options.permission, { user }).catch(() => false);
|
|
468
|
+
};
|
|
469
|
+
return await handleMcp(app, req, headers, testUsers, visible);
|
|
362
470
|
}
|
|
363
471
|
const lookup = method === "HEAD" ? "GET" : method;
|
|
364
472
|
let match;
|
|
@@ -390,8 +498,18 @@ export function createApp(config = {}) {
|
|
|
390
498
|
const limit = opts.timeoutMs === undefined ? requestTimeoutMs : opts.timeoutMs;
|
|
391
499
|
// timeout aborts when the route takes too long; signal (ctx.signal) also when the client disconnects.
|
|
392
500
|
const timeout = new AbortController();
|
|
393
|
-
|
|
501
|
+
// Combined on first use: AbortSignal.any is costly, and most handlers never read ctx.signal.
|
|
502
|
+
let combined;
|
|
503
|
+
const signal = () => (combined ??= AbortSignal.any([clientSignals.get(req) ?? req.signal, timeout.signal]));
|
|
394
504
|
let requestDb;
|
|
505
|
+
let queues;
|
|
506
|
+
let requestUser = null;
|
|
507
|
+
const tenantQueues = () => {
|
|
508
|
+
if (queues)
|
|
509
|
+
return queues;
|
|
510
|
+
const scope = tenantScope(requestUser);
|
|
511
|
+
return (queues = typeof scope === "object" ? withTenant({ jobs, events }, scope.tenant) : { jobs, events });
|
|
512
|
+
};
|
|
395
513
|
const run = async () => {
|
|
396
514
|
// Auth
|
|
397
515
|
let user = null;
|
|
@@ -399,6 +517,7 @@ export function createApp(config = {}) {
|
|
|
399
517
|
user = testUsers.get(req) ?? null;
|
|
400
518
|
else if (config.auth)
|
|
401
519
|
user = (await config.auth.resolve(req, { db: db, url })) ?? null;
|
|
520
|
+
requestUser = user;
|
|
402
521
|
const level = opts.auth ?? "user";
|
|
403
522
|
if (level !== "public") {
|
|
404
523
|
if (!user)
|
|
@@ -406,9 +525,14 @@ export function createApp(config = {}) {
|
|
|
406
525
|
if (level !== "user" && !roles(user).includes(level))
|
|
407
526
|
throw new InferloError("IL3002", `This requires the "${level}" role.`, { status: 403 });
|
|
408
527
|
}
|
|
409
|
-
|
|
528
|
+
if (opts.permission)
|
|
529
|
+
await config.permissions.authorize(opts.permission, { user });
|
|
530
|
+
// Rate limit: counted per user, tenant or IP, after auth so failed sign-ins on private routes do not use it up.
|
|
531
|
+
// by "tenant" counts users without a tenant one by one; by "user" and "tenant" count requests without a user by IP.
|
|
410
532
|
if (opts.rateLimit) {
|
|
411
|
-
const
|
|
533
|
+
const by = opts.rateLimit.by ?? "user";
|
|
534
|
+
const scope = by === "tenant" && user ? tenantScope(user) : "none";
|
|
535
|
+
const who = typeof scope === "object" ? `tenant:${scope.tenant}` : by !== "ip" && user ? `user:${user.id}` : `ip:${ip}`;
|
|
412
536
|
const hit = await rateLimits.hit(`${def.method} ${def.path} ${who}`, windowMs(opts.rateLimit));
|
|
413
537
|
const limited = rateLimitResult(opts.rateLimit, hit);
|
|
414
538
|
for (const [k, v] of Object.entries(limited.headers))
|
|
@@ -432,8 +556,12 @@ export function createApp(config = {}) {
|
|
|
432
556
|
const type = req.headers.get("content-type") ?? "";
|
|
433
557
|
let raw = undefined;
|
|
434
558
|
if (text) {
|
|
435
|
-
if (type.includes("application/x-www-form-urlencoded"))
|
|
436
|
-
|
|
559
|
+
if (type.includes("application/x-www-form-urlencoded")) {
|
|
560
|
+
// _csrf is the sessions() CSRF field of HTML forms, checked before the handler and not part of the body.
|
|
561
|
+
const form = new URLSearchParams(text);
|
|
562
|
+
form.delete("_csrf");
|
|
563
|
+
raw = Object.fromEntries(form);
|
|
564
|
+
}
|
|
437
565
|
else {
|
|
438
566
|
try {
|
|
439
567
|
raw = JSON.parse(text);
|
|
@@ -445,6 +573,34 @@ export function createApp(config = {}) {
|
|
|
445
573
|
}
|
|
446
574
|
body = parseOrThrow(opts.body, raw, "request body", type.includes("application/x-www-form-urlencoded"));
|
|
447
575
|
}
|
|
576
|
+
// Response cache: after auth, permissions, rate limits and input checks, so a cached response is only
|
|
577
|
+
// served to callers who may call the route with valid input.
|
|
578
|
+
const routeCache = opts.cache;
|
|
579
|
+
const cacheKey = routeCache ? await routeCacheKey(def.method, def.path, routeCache, user?.id, url, routeCache.by === "tenant" && user ? tenantOf(tenantScope(user)) : undefined) : undefined;
|
|
580
|
+
const cacheHeaders = (outcome, etag) => {
|
|
581
|
+
headers.set("x-inferlo-cache", outcome);
|
|
582
|
+
headers.set("etag", etag);
|
|
583
|
+
if (!headers.has("cache-control"))
|
|
584
|
+
headers.set("cache-control", `${(routeCache.by ?? "user") === "public" && (opts.auth ?? "user") === "public" ? "public" : "private"}, no-cache`);
|
|
585
|
+
if ((routeCache.by ?? "user") !== "public")
|
|
586
|
+
headers.append("vary", "Authorization, Cookie");
|
|
587
|
+
};
|
|
588
|
+
const cachedAnswer = (body, etag) => {
|
|
589
|
+
if (etagMatches(req.headers.get("if-none-match"), etag))
|
|
590
|
+
return new Response(null, { status: 304, headers });
|
|
591
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
592
|
+
return new Response(method === "HEAD" ? null : body, { status: 200, headers });
|
|
593
|
+
};
|
|
594
|
+
if (cacheKey) {
|
|
595
|
+
const hit = await cache.get(cacheKey);
|
|
596
|
+
if (hit) {
|
|
597
|
+
for (const [name, value] of hit.headers)
|
|
598
|
+
headers.set(name, value);
|
|
599
|
+
cacheHeaders("HIT", hit.etag);
|
|
600
|
+
return cachedAnswer(JSON.stringify(hit.body), hit.etag);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const handlerHeaders = [];
|
|
448
604
|
let status = 0;
|
|
449
605
|
const ctx = {
|
|
450
606
|
body,
|
|
@@ -453,30 +609,67 @@ export function createApp(config = {}) {
|
|
|
453
609
|
user,
|
|
454
610
|
get db() {
|
|
455
611
|
// Built on first use: after a timeout, new queries fail with IL2007 instead of running.
|
|
456
|
-
|
|
612
|
+
// Tenant-scoped tables are filtered by the signed-in user's tenant (none without one).
|
|
613
|
+
return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal), tenantScope(user)));
|
|
614
|
+
},
|
|
615
|
+
// ctx.ai and ctx.audit are built on first use too, so routes that do not use them pay nothing.
|
|
616
|
+
get ai() {
|
|
617
|
+
const value = toolsFor(req, signal());
|
|
618
|
+
Object.defineProperty(ctx, "ai", { value, enumerable: true });
|
|
619
|
+
return value;
|
|
620
|
+
},
|
|
621
|
+
get signal() {
|
|
622
|
+
return signal();
|
|
457
623
|
},
|
|
458
|
-
ai: toolsFor(req, signal),
|
|
459
|
-
signal,
|
|
460
624
|
requestId,
|
|
461
|
-
jobs
|
|
625
|
+
// With a tenant, jobs and events queued by the request carry it.
|
|
626
|
+
get jobs() {
|
|
627
|
+
return tenantQueues().jobs;
|
|
628
|
+
},
|
|
629
|
+
get events() {
|
|
630
|
+
return tenantQueues().events;
|
|
631
|
+
},
|
|
632
|
+
cache,
|
|
462
633
|
req,
|
|
463
634
|
url,
|
|
464
635
|
headers: req.headers,
|
|
465
636
|
state,
|
|
466
637
|
status: (code) => void (status = code),
|
|
467
|
-
header: (name, value) =>
|
|
638
|
+
header: (name, value) => {
|
|
639
|
+
// Set-Cookie is added, not replaced, so a handler can set several cookies.
|
|
640
|
+
if (name.toLowerCase() === "set-cookie")
|
|
641
|
+
headers.append(name, value);
|
|
642
|
+
else
|
|
643
|
+
headers.set(name, value);
|
|
644
|
+
handlerHeaders.push([name.toLowerCase(), value]);
|
|
645
|
+
},
|
|
468
646
|
waitUntil: (p) => void pending.push(p.catch((e) => logger.error("waitUntil task failed", e, { requestId }))),
|
|
647
|
+
get audit() {
|
|
648
|
+
const value = createAudit(models, redactor, () => ctx.db, { userId: user?.id ?? null, tenantId: tenantOf(tenantScope(user)), source: `${def.method} ${def.path}`, requestId });
|
|
649
|
+
Object.defineProperty(ctx, "audit", { value, enumerable: true });
|
|
650
|
+
return value;
|
|
651
|
+
},
|
|
469
652
|
};
|
|
470
653
|
const result = await def.handler(ctx);
|
|
471
654
|
if (result instanceof Response) {
|
|
472
655
|
headers.forEach((v, k) => {
|
|
473
|
-
if (!result.headers.has(k))
|
|
656
|
+
if (k !== "set-cookie" && !result.headers.has(k))
|
|
474
657
|
result.headers.set(k, v);
|
|
475
658
|
});
|
|
659
|
+
for (const cookie of headers.getSetCookie())
|
|
660
|
+
result.headers.append("set-cookie", cookie);
|
|
476
661
|
return result;
|
|
477
662
|
}
|
|
478
663
|
if (result === undefined)
|
|
479
664
|
return new Response(null, { status: status || 204, headers });
|
|
665
|
+
// Only plain 200 JSON answers are cached, never one that sets a cookie.
|
|
666
|
+
if (cacheKey && (status || 200) === 200 && !handlerHeaders.some(([name]) => name === "set-cookie")) {
|
|
667
|
+
const body = JSON.stringify(result);
|
|
668
|
+
const etag = await etagFor(body);
|
|
669
|
+
await cache.set(cacheKey, { body: JSON.parse(body), headers: handlerHeaders, etag }, { ttlMs: routeCache.ttlMs, tags: routeCache.tags ?? [] });
|
|
670
|
+
cacheHeaders("MISS", etag);
|
|
671
|
+
return cachedAnswer(body, etag);
|
|
672
|
+
}
|
|
480
673
|
const response = json(result, status || 200, headers);
|
|
481
674
|
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
482
675
|
};
|
|
@@ -540,8 +733,29 @@ export function createApp(config = {}) {
|
|
|
540
733
|
}
|
|
541
734
|
};
|
|
542
735
|
async function handle(req, rest) {
|
|
543
|
-
const started = performance.now();
|
|
544
736
|
const url = new URL(req.url);
|
|
737
|
+
// Health and metrics endpoints are called every few seconds by load balancers and scrapers: not logged, counted or traced.
|
|
738
|
+
const probe = url.pathname === health?.path || url.pathname === health?.readyPath || url.pathname === metricsConfig?.path;
|
|
739
|
+
if (!tracing || probe)
|
|
740
|
+
return handleTraced(req, rest, url, probe, undefined);
|
|
741
|
+
return tracing.run(req, url, async (span) => {
|
|
742
|
+
requestSpans.set(req, span);
|
|
743
|
+
let status = 500;
|
|
744
|
+
try {
|
|
745
|
+
const response = await handleTraced(req, rest, url, probe, span);
|
|
746
|
+
status = response.status;
|
|
747
|
+
return response;
|
|
748
|
+
}
|
|
749
|
+
finally {
|
|
750
|
+
span.setAttribute("http.response.status_code", status);
|
|
751
|
+
if (status >= 500)
|
|
752
|
+
span.setStatus({ code: tracingApi.SpanStatusCode.ERROR });
|
|
753
|
+
span.end();
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
async function handleTraced(req, rest, url, probe, span) {
|
|
758
|
+
const started = performance.now();
|
|
545
759
|
// Behind a trusted proxy, keep the id it sent so log entries line up across services.
|
|
546
760
|
const incoming = config.trustProxy ? req.headers.get("x-request-id") : null;
|
|
547
761
|
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
@@ -567,15 +781,17 @@ export function createApp(config = {}) {
|
|
|
567
781
|
response = errorResponse(error, new Headers(), req, requestId);
|
|
568
782
|
}
|
|
569
783
|
response = withHeader(response, "x-request-id", requestId);
|
|
570
|
-
|
|
571
|
-
|
|
784
|
+
if (span && seen.route !== "unmatched") {
|
|
785
|
+
span.updateName(`${req.method.toUpperCase()} ${seen.route}`);
|
|
786
|
+
span.setAttribute("http.route", seen.route);
|
|
787
|
+
}
|
|
572
788
|
if (!probe) {
|
|
573
789
|
const elapsed = performance.now() - started;
|
|
574
790
|
const method = req.method.toUpperCase();
|
|
575
791
|
metrics?.request(method, seen.route, response.status, elapsed / 1000);
|
|
576
792
|
if (logging.requests) {
|
|
577
793
|
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 });
|
|
794
|
+
logger.info(`${method} ${url.pathname} ${response.status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status: response.status, durationMs });
|
|
579
795
|
}
|
|
580
796
|
}
|
|
581
797
|
if (secureHeaders.length) {
|
|
@@ -584,9 +800,9 @@ export function createApp(config = {}) {
|
|
|
584
800
|
}
|
|
585
801
|
if (pending.length) {
|
|
586
802
|
const all = Promise.all(pending);
|
|
587
|
-
const execution = rest
|
|
588
|
-
if (execution
|
|
589
|
-
execution
|
|
803
|
+
const execution = platformWaitUntil(rest);
|
|
804
|
+
if (execution)
|
|
805
|
+
execution(all);
|
|
590
806
|
else {
|
|
591
807
|
// Kept until it settles, so app.close() waits for it.
|
|
592
808
|
tasks.add(all);
|
|
@@ -603,9 +819,12 @@ export function createApp(config = {}) {
|
|
|
603
819
|
version: config.version ?? "0.0.0",
|
|
604
820
|
routes,
|
|
605
821
|
models,
|
|
822
|
+
modules,
|
|
606
823
|
db,
|
|
607
824
|
ai,
|
|
608
825
|
jobs,
|
|
826
|
+
events,
|
|
827
|
+
cache,
|
|
609
828
|
get closing() {
|
|
610
829
|
return closing;
|
|
611
830
|
},
|
|
@@ -613,7 +832,10 @@ export function createApp(config = {}) {
|
|
|
613
832
|
inFlight++;
|
|
614
833
|
metrics?.inFlight(1);
|
|
615
834
|
try {
|
|
616
|
-
|
|
835
|
+
const response = await handle(req, rest);
|
|
836
|
+
if (rest.length)
|
|
837
|
+
keepEventStreamOpen(rest[0], req, response);
|
|
838
|
+
return response;
|
|
617
839
|
}
|
|
618
840
|
finally {
|
|
619
841
|
inFlight--;
|
|
@@ -706,15 +928,37 @@ export function createApp(config = {}) {
|
|
|
706
928
|
requestTimeoutMs,
|
|
707
929
|
logging: { format: config.logger ? "custom" : logging.format, requests: logging.requests },
|
|
708
930
|
description: config.description,
|
|
931
|
+
modules: moduleInfo,
|
|
932
|
+
versions: versions.map((v) => ({ name: v, openapi: openapiPath ? versionDocPath(v, openapiPath) : false })),
|
|
933
|
+
permissions: config.permissions?.list.map((p) => (p.roles ? { name: p.name, roles: [...p.roles] } : { name: p.name })) ?? [],
|
|
709
934
|
});
|
|
710
935
|
},
|
|
711
|
-
openapi() {
|
|
712
|
-
|
|
936
|
+
openapi(options = {}) {
|
|
937
|
+
if (options.version !== undefined && !versions.includes(options.version)) {
|
|
938
|
+
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."}`, {
|
|
939
|
+
fix: 'Pass a version used in apiVersion("v1", routes), or call app.openapi() for every route.',
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
return buildOpenAPI(app, { description: config.description, auth: Boolean(config.auth), modules: moduleInfo, version: options.version });
|
|
713
943
|
},
|
|
714
944
|
};
|
|
945
|
+
// Built-in endpoints with the methods dispatch answers them for (MCP takes every method).
|
|
946
|
+
const readEndpoints = [health?.path, health?.readyPath, metricsConfig?.path].filter((p) => typeof p === "string");
|
|
947
|
+
registerRequestMatcher(app, (rawMethod, pathname) => {
|
|
948
|
+
const method = rawMethod.toUpperCase();
|
|
949
|
+
if (mcpPath && pathname === mcpPath)
|
|
950
|
+
return true;
|
|
951
|
+
if (method === "GET" && ((openapiPath && pathname === openapiPath) || versionDocs.has(pathname)))
|
|
952
|
+
return true;
|
|
953
|
+
if ((method === "GET" || method === "HEAD") && readEndpoints.includes(pathname))
|
|
954
|
+
return true;
|
|
955
|
+
const lookup = method === "HEAD" ? "GET" : method;
|
|
956
|
+
// A CORS preflight belongs to the app when any of its routes has this path.
|
|
957
|
+
return compiled.some((c) => (method === "OPTIONS" || c.def.method === lookup) && c.regex.test(pathname));
|
|
958
|
+
});
|
|
715
959
|
return app;
|
|
716
960
|
}
|
|
717
|
-
function resolveHealth(option, routes, taken) {
|
|
961
|
+
function resolveHealth(option, routes, taken, builtIn) {
|
|
718
962
|
if (option === undefined || option === false)
|
|
719
963
|
return undefined;
|
|
720
964
|
const bad = (message, fix) => new InferloError("IL6014", message, { fix });
|
|
@@ -731,9 +975,9 @@ function resolveHealth(option, routes, taken) {
|
|
|
731
975
|
if (!Number.isFinite(resolved.timeoutMs) || resolved.timeoutMs <= 0)
|
|
732
976
|
throw bad(`health.timeoutMs must be a positive number of milliseconds, got ${String(resolved.timeoutMs)}.`, "Use health: { timeoutMs: 5000 }.");
|
|
733
977
|
if (typeof resolved.checks !== "object" || resolved.checks === null)
|
|
734
|
-
throw bad("health.checks must be an object of functions.", "Use health: { checks: {
|
|
978
|
+
throw bad("health.checks must be an object of functions.", "Use health: { checks: { search: () => search.ping() } }.");
|
|
735
979
|
for (const [name, check] of Object.entries(resolved.checks)) {
|
|
736
|
-
if (name === "database" || name === "ai")
|
|
980
|
+
if (name === "database" || name === "ai" || builtIn.includes(name))
|
|
737
981
|
throw bad(`health.checks.${name} is built in.`, `Give your check another name, for example "${name}Replica".`);
|
|
738
982
|
if (typeof check !== "function")
|
|
739
983
|
throw bad(`health.checks.${name} must be a function.`, `Use health: { checks: { ${name}: async () => { ... } } }; throw when it is not ready.`);
|