@browserlessapi/inferlo 0.3.0 → 0.5.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 +18 -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 +51 -2
- package/dist/ai/index.js +186 -37
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/providers.d.ts +56 -3
- package/dist/ai/providers.js +102 -11
- package/dist/ai/providers.js.map +1 -1
- package/dist/ai/public.d.ts +20 -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/app.d.ts +102 -2
- package/dist/app.js +509 -111
- package/dist/app.js.map +1 -1
- package/dist/cli/main.js +76 -4
- package/dist/cli/main.js.map +1 -1
- package/dist/client-ip.d.ts +7 -0
- package/dist/client-ip.js +27 -0
- package/dist/client-ip.js.map +1 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +101 -0
- package/dist/config.js.map +1 -0
- package/dist/cron.d.ts +17 -0
- package/dist/cron.js +143 -0
- package/dist/cron.js.map +1 -0
- package/dist/db/migrate.d.ts +6 -3
- package/dist/db/migrate.js +126 -10
- package/dist/db/migrate.js.map +1 -1
- package/dist/db/table.d.ts +83 -7
- package/dist/db/table.js +245 -9
- 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 +45 -5
- package/dist/describe.js +19 -2
- 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 +44 -0
- package/dist/fastify.js +47 -0
- package/dist/fastify.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 +20 -6
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +181 -0
- package/dist/jobs.js +391 -0
- package/dist/jobs.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 +4 -6
- package/dist/mcp.js +53 -38
- 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/nestjs.d.ts +43 -0
- package/dist/nestjs.js +47 -0
- package/dist/nestjs.js.map +1 -0
- package/dist/openapi.d.ts +0 -1
- package/dist/openapi.js +7 -0
- package/dist/openapi.js.map +1 -1
- package/dist/rate-limit.d.ts +45 -0
- package/dist/rate-limit.js +93 -0
- package/dist/rate-limit.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/route.d.ts +24 -0
- package/dist/route.js.map +1 -1
- package/dist/schema.d.ts +10 -2
- package/dist/schema.js +13 -1
- package/dist/schema.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 +53 -1
- package/dist/serve.js +158 -59
- package/dist/serve.js.map +1 -1
- 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 +10 -4
- package/package.json +57 -3
- package/templates/app/AGENTS.md +14 -8
- package/templates/app/README.md +2 -0
- package/templates/app/_env.example +2 -0
- package/templates/app/package.json.tpl +1 -0
- package/templates/app/src/app.ts +15 -8
- package/templates/app/src/config.ts +17 -0
- package/templates/app/src/routes/notes.ts +1 -1
- package/templates/app/src/server.ts +13 -3
- package/templates/app/src/routes/health.ts +0 -3
package/dist/app.js
CHANGED
|
@@ -2,15 +2,24 @@
|
|
|
2
2
|
* createApp wires models, routes, database, auth and AI together and gives you a standard
|
|
3
3
|
* fetch(Request) => Response handler that runs on Node, Bun, Deno and edge runtimes.
|
|
4
4
|
*/
|
|
5
|
-
import { createAI } from "./ai/index.js";
|
|
5
|
+
import { bindTools, createAI } from "./ai/index.js";
|
|
6
6
|
import { memory } from "./db/memory.js";
|
|
7
|
-
import { createDb } from "./db/table.js";
|
|
7
|
+
import { bindDb, createDb, guardAdapter } from "./db/table.js";
|
|
8
8
|
import { describeApp } from "./describe.js";
|
|
9
9
|
import { InferloError, didYouMean, suggest } from "./errors.js";
|
|
10
|
-
import { handleMcp } from "./mcp.js";
|
|
10
|
+
import { callTool, handleMcp, toolDefinition } from "./mcp.js";
|
|
11
11
|
import { buildOpenAPI } from "./openapi.js";
|
|
12
12
|
import { isRoute } from "./route.js";
|
|
13
|
+
import { checkRateLimit, databaseRateLimitStore, memoryRateLimitStore, rateLimitResult, windowMs } from "./rate-limit.js";
|
|
14
|
+
import { clientIp, clientIps } from "./client-ip.js";
|
|
15
|
+
import { collectCrons, collectJobs, createJobs, jobStoreFor, stopWorkers } from "./jobs.js";
|
|
13
16
|
import { ValidationError } from "./schema.js";
|
|
17
|
+
import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
|
|
18
|
+
import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
|
|
19
|
+
import { resolveDeprecation } from "./deprecation.js";
|
|
20
|
+
import { createMetrics, hasMetricsToken, resolveMetrics } from "./metrics.js";
|
|
21
|
+
import { INFERLO_VERSION } from "./describe.js";
|
|
22
|
+
import { applySecureHeaders, resolveSecureHeaders } from "./secure-headers.js";
|
|
14
23
|
const testUsers = new WeakMap();
|
|
15
24
|
function compile(def) {
|
|
16
25
|
if (!def.path.startsWith("/")) {
|
|
@@ -68,13 +77,15 @@ function corsHeaders(config, origin) {
|
|
|
68
77
|
h["access-control-allow-credentials"] = "true";
|
|
69
78
|
return h;
|
|
70
79
|
}
|
|
71
|
-
async function readBody(req, limit) {
|
|
80
|
+
async function readBody(req, limit, signal) {
|
|
72
81
|
const length = Number(req.headers.get("content-length") ?? "0");
|
|
73
82
|
if (length > limit)
|
|
74
83
|
throw new InferloError("IL2004", `Request body is larger than ${limit} bytes.`, { status: 413 });
|
|
75
84
|
if (!req.body)
|
|
76
85
|
return "";
|
|
77
86
|
const reader = req.body.getReader();
|
|
87
|
+
// Stop reading a slow body once the request timed out.
|
|
88
|
+
signal?.addEventListener("abort", () => void reader.cancel().catch(() => { }), { once: true });
|
|
78
89
|
const chunks = [];
|
|
79
90
|
let size = 0;
|
|
80
91
|
while (true) {
|
|
@@ -104,15 +115,29 @@ function queryObject(url) {
|
|
|
104
115
|
}
|
|
105
116
|
return out;
|
|
106
117
|
}
|
|
118
|
+
/** Sets a header unless the response already has it; copies responses with immutable headers. */
|
|
119
|
+
function withHeader(response, name, value) {
|
|
120
|
+
if (response.headers.has(name))
|
|
121
|
+
return response;
|
|
122
|
+
try {
|
|
123
|
+
response.headers.set(name, value);
|
|
124
|
+
return response;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
const copy = new Response(response.body, response);
|
|
128
|
+
copy.headers.set(name, value);
|
|
129
|
+
return copy;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
107
132
|
function json(data, status, headers) {
|
|
108
133
|
headers.set("content-type", "application/json; charset=utf-8");
|
|
109
134
|
return new Response(JSON.stringify(data), { status, headers });
|
|
110
135
|
}
|
|
111
136
|
export function createApp(config = {}) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
137
|
+
// Everything the app logs goes through the redactor: credentials and secrets never reach the logs.
|
|
138
|
+
const redactor = createRedactor([...redactValues(config.redact), ...Object.values(config.ai?.providers ?? {}).flatMap((p) => secretsOf(p))]);
|
|
139
|
+
const logging = loggingOptions(config.logging, config.logger !== undefined);
|
|
140
|
+
const logger = redactingLogger(config.logger ?? defaultLogger(logging.format, config.name ?? "inferlo-app"), redactor);
|
|
116
141
|
const models = (config.models ?? []);
|
|
117
142
|
const modelNames = new Set();
|
|
118
143
|
for (const m of models) {
|
|
@@ -155,29 +180,147 @@ export function createApp(config = {}) {
|
|
|
155
180
|
fix: 'Routes are private by default. Add auth: "public" to routes anyone may call, or pass createApp({ auth: { resolve(req) { ... } } }).',
|
|
156
181
|
});
|
|
157
182
|
}
|
|
183
|
+
for (const r of routes)
|
|
184
|
+
if (r.options.rateLimit !== undefined)
|
|
185
|
+
checkRateLimit(r.options.rateLimit, `${r.method} ${r.path}`);
|
|
186
|
+
const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
|
|
187
|
+
for (const r of routes)
|
|
188
|
+
if (r.options.timeoutMs !== undefined)
|
|
189
|
+
checkTimeout(r.options.timeoutMs, `${r.method} ${r.path} { timeoutMs }`);
|
|
190
|
+
const deprecations = new Map();
|
|
191
|
+
for (const r of routes) {
|
|
192
|
+
const resolved = resolveDeprecation(r.options.deprecated, `${r.method} ${r.path}`);
|
|
193
|
+
if (resolved)
|
|
194
|
+
deprecations.set(r, resolved);
|
|
195
|
+
}
|
|
196
|
+
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: "database" }.' });
|
|
199
|
+
}
|
|
200
|
+
const openapiPath = config.openapi === undefined ? "/openapi.json" : config.openapi;
|
|
201
|
+
const hasTools = routes.some((r) => r.options.tool);
|
|
202
|
+
const mcpPath = config.mcp === undefined ? (hasTools ? "/mcp" : false) : config.mcp;
|
|
203
|
+
const health = resolveHealth(config.health, routes, [openapiPath, mcpPath]);
|
|
204
|
+
const metricsConfig = resolveMetrics(config.metrics, [
|
|
205
|
+
{ path: openapiPath, what: "the OpenAPI path" },
|
|
206
|
+
{ path: mcpPath, what: "the MCP path" },
|
|
207
|
+
{ path: health?.path ?? false, what: "the health path" },
|
|
208
|
+
{ path: health?.readyPath ?? false, what: "the readiness path" },
|
|
209
|
+
...routes.filter((r) => r.method === "GET").map((r) => ({ path: r.path, what: `the route GET ${r.path}` })),
|
|
210
|
+
]);
|
|
158
211
|
const adapter = config.db ?? memory();
|
|
159
212
|
const db = createDb(models, adapter);
|
|
160
|
-
const
|
|
213
|
+
const rateLimits = storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
|
|
214
|
+
const metrics = metricsConfig
|
|
215
|
+
? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
|
|
216
|
+
: undefined;
|
|
217
|
+
// With metrics on, every AI call that reports usage is counted before the app's own onUsage runs.
|
|
218
|
+
const aiConfig = metrics && config.ai
|
|
219
|
+
? { ...config.ai, onUsage: (usage) => (metrics.ai(usage), config.ai.onUsage?.(usage)) }
|
|
220
|
+
: config.ai;
|
|
221
|
+
const ai = createAI(aiConfig);
|
|
222
|
+
const jobs = createJobs(collectJobs(config.jobs), {
|
|
223
|
+
db: db,
|
|
224
|
+
ai,
|
|
225
|
+
store: jobStoreFor(config.jobQueue, adapter),
|
|
226
|
+
queue: config.jobQueue?.store ?? "memory",
|
|
227
|
+
logger,
|
|
228
|
+
onRun: metrics ? (name, outcome, ms) => metrics.job(name, outcome, ms / 1000) : undefined,
|
|
229
|
+
}, collectCrons(config.jobs));
|
|
161
230
|
const maxBody = config.maxBodySize ?? 1024 * 1024;
|
|
231
|
+
const secureHeaders = resolveSecureHeaders(config.secureHeaders);
|
|
162
232
|
const cors = config.cors === true ? { origin: "*" } : config.cors || undefined;
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
233
|
+
// Readiness: the database answers, every AI alias points to a configured provider, and the app's own checks pass.
|
|
234
|
+
const readinessChecks = {
|
|
235
|
+
database: async () => {
|
|
236
|
+
if (adapter.sql)
|
|
237
|
+
await adapter.sql("select 1");
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
const aiProviders = Object.keys(config.ai?.providers ?? {});
|
|
241
|
+
if (aiProviders.length || Object.keys(config.ai?.models ?? {}).length) {
|
|
242
|
+
readinessChecks.ai = () => {
|
|
243
|
+
for (const [alias, targets] of Object.entries(ai.aliases)) {
|
|
244
|
+
for (const target of targets) {
|
|
245
|
+
const provider = target.split(":")[0];
|
|
246
|
+
if (!aiProviders.includes(provider)) {
|
|
247
|
+
throw new InferloError("IL5001", `AI alias "${alias}" uses provider "${provider}", which is not configured.`, { status: 503, expose: true });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
Object.assign(readinessChecks, health?.checks);
|
|
254
|
+
const runCheck = async (name, check) => {
|
|
255
|
+
const started = Date.now();
|
|
256
|
+
const timeoutMs = health?.timeoutMs ?? 5000;
|
|
257
|
+
let timer;
|
|
258
|
+
const timedOut = Symbol("timeout");
|
|
259
|
+
try {
|
|
260
|
+
const outcome = await Promise.race([
|
|
261
|
+
Promise.resolve().then(check),
|
|
262
|
+
new Promise((resolve) => {
|
|
263
|
+
timer = setTimeout(() => resolve(timedOut), timeoutMs);
|
|
264
|
+
}),
|
|
265
|
+
]);
|
|
266
|
+
if (outcome === timedOut) {
|
|
267
|
+
logger.error(`Readiness check "${name}" timed out after ${timeoutMs} ms`);
|
|
268
|
+
return { status: "error", durationMs: Date.now() - started, error: `Timed out after ${timeoutMs} ms.` };
|
|
269
|
+
}
|
|
270
|
+
return { status: "ok", durationMs: Date.now() - started };
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
logger.error(`Readiness check "${name}" failed`, error);
|
|
274
|
+
const shown = (error instanceof InferloError && error.expose) || (config.exposeErrors && error instanceof Error);
|
|
275
|
+
return { status: "error", durationMs: Date.now() - started, error: shown ? redactor.text(error.message) : "Check failed." };
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
// Routes passed to ai.generate({ tools }) are matched to the registered route by method and path.
|
|
282
|
+
const registeredTool = (route) => {
|
|
283
|
+
const found = routes.find((r) => r.method === route?.method && r.path === route?.path);
|
|
284
|
+
if (!found) {
|
|
285
|
+
throw new InferloError("IL5011", `Tool ${route?.method} ${route?.path} is not a route of this app.`, {
|
|
286
|
+
fix: "Pass routes that are registered in createApp({ routes }) to ai.generate({ tools }).",
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return toolDefinition(found);
|
|
290
|
+
};
|
|
291
|
+
const describeTool = (route) => {
|
|
292
|
+
const { name, description, inputSchema } = registeredTool(route);
|
|
293
|
+
return { name, description, inputSchema };
|
|
294
|
+
};
|
|
295
|
+
const toolsFor = (req, signal) => bindTools(ai, async (route, input) => {
|
|
296
|
+
const tool = registeredTool(route);
|
|
297
|
+
const response = await callTool(app, tool, input, req, testUsers);
|
|
298
|
+
const text = await response.text();
|
|
299
|
+
let output = text;
|
|
300
|
+
try {
|
|
301
|
+
output = text ? JSON.parse(text) : null;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Not JSON: the model sees the text as it is.
|
|
305
|
+
}
|
|
306
|
+
return { name: tool.name, status: response.status, output, text: text || String(response.status) };
|
|
307
|
+
}, describeTool, signal);
|
|
166
308
|
const roles = (user) => config.auth?.roles?.(user) ?? (user.roles ?? []);
|
|
167
|
-
const errorResponse = (error, headers, req) => {
|
|
309
|
+
const errorResponse = (error, headers, req, requestId) => {
|
|
310
|
+
const fields = { requestId };
|
|
168
311
|
if (error instanceof InferloError) {
|
|
169
312
|
if (error.status >= 500)
|
|
170
|
-
logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "");
|
|
313
|
+
logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "", fields);
|
|
171
314
|
const body = error.toJSON();
|
|
172
315
|
if (config.exposeErrors && !error.expose)
|
|
173
|
-
body.error.message = error.message;
|
|
316
|
+
body.error.message = redactor.text(error.message);
|
|
174
317
|
return json(body, error.status, headers);
|
|
175
318
|
}
|
|
176
|
-
logger.error(`${req.method} ${new URL(req.url).pathname} failed`, error);
|
|
177
|
-
const message = config.exposeErrors && error instanceof Error ? error.message : "Internal server error";
|
|
319
|
+
logger.error(`${req.method} ${new URL(req.url).pathname} failed`, error, fields);
|
|
320
|
+
const message = config.exposeErrors && error instanceof Error ? redactor.text(error.message) : "Internal server error";
|
|
178
321
|
return json({ error: { code: "IL9000", message, docs: new InferloError("IL9000", "").docs } }, 500, headers);
|
|
179
322
|
};
|
|
180
|
-
const dispatch = async (req, url, state, pending) => {
|
|
323
|
+
const dispatch = async (req, url, state, pending, ip, requestId, seen) => {
|
|
181
324
|
const headers = new Headers();
|
|
182
325
|
if (cors)
|
|
183
326
|
for (const [k, v] of Object.entries(corsHeaders(cors, req.headers.get("origin"))))
|
|
@@ -190,10 +333,33 @@ export function createApp(config = {}) {
|
|
|
190
333
|
headers.set("access-control-max-age", String(cors.maxAge ?? 600));
|
|
191
334
|
return new Response(null, { status: 204, headers });
|
|
192
335
|
}
|
|
193
|
-
if (
|
|
336
|
+
if (health && (method === "GET" || method === "HEAD") && (url.pathname === health.path || url.pathname === health.readyPath)) {
|
|
337
|
+
headers.set("cache-control", "no-store");
|
|
338
|
+
const report = url.pathname === health.path ? { status: "ok" } : await app.checkReady();
|
|
339
|
+
const response = json(report, report.status === "not_ready" ? 503 : 200, headers);
|
|
340
|
+
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
341
|
+
}
|
|
342
|
+
if (metricsConfig && url.pathname === metricsConfig.path && (method === "GET" || method === "HEAD")) {
|
|
343
|
+
headers.set("cache-control", "no-store");
|
|
344
|
+
if (metricsConfig.token && !hasMetricsToken(req, metricsConfig.token)) {
|
|
345
|
+
headers.set("www-authenticate", 'Bearer realm="metrics"');
|
|
346
|
+
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)." });
|
|
347
|
+
}
|
|
348
|
+
headers.set("content-type", "text/plain; version=0.0.4; charset=utf-8");
|
|
349
|
+
return new Response(method === "HEAD" ? null : metrics.render(), { status: 200, headers });
|
|
350
|
+
}
|
|
351
|
+
if (refusing) {
|
|
352
|
+
headers.set("connection", "close");
|
|
353
|
+
throw new InferloError("IL2006", "The server is shutting down.", { status: 503, fix: "Retry the request; another instance will answer it." });
|
|
354
|
+
}
|
|
355
|
+
if (openapiPath && url.pathname === openapiPath && method === "GET") {
|
|
356
|
+
seen.route = openapiPath;
|
|
194
357
|
return json(app.openapi(), 200, headers);
|
|
195
|
-
|
|
358
|
+
}
|
|
359
|
+
if (mcpPath && url.pathname === mcpPath) {
|
|
360
|
+
seen.route = mcpPath;
|
|
196
361
|
return await handleMcp(app, req, headers, testUsers);
|
|
362
|
+
}
|
|
197
363
|
const lookup = method === "HEAD" ? "GET" : method;
|
|
198
364
|
let match;
|
|
199
365
|
const allowed = [];
|
|
@@ -216,82 +382,222 @@ export function createApp(config = {}) {
|
|
|
216
382
|
throw new InferloError("IL2001", `No route matches ${method} ${url.pathname}.${near ? ` Closest route: ${near}.` : ""}`, { status: 404 });
|
|
217
383
|
}
|
|
218
384
|
const { def, keys } = match.route;
|
|
385
|
+
seen.route = def.path;
|
|
386
|
+
// Deprecation headers go on every answer of the route, errors included.
|
|
387
|
+
for (const [name, value] of deprecations.get(def)?.headers ?? [])
|
|
388
|
+
headers.set(name, value);
|
|
219
389
|
const opts = def.options;
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
390
|
+
const limit = opts.timeoutMs === undefined ? requestTimeoutMs : opts.timeoutMs;
|
|
391
|
+
// timeout aborts when the route takes too long; signal (ctx.signal) also when the client disconnects.
|
|
392
|
+
const timeout = new AbortController();
|
|
393
|
+
const signal = AbortSignal.any([req.signal, timeout.signal]);
|
|
394
|
+
let requestDb;
|
|
395
|
+
const run = async () => {
|
|
396
|
+
// Auth
|
|
397
|
+
let user = null;
|
|
398
|
+
if (testUsers.has(req))
|
|
399
|
+
user = testUsers.get(req) ?? null;
|
|
400
|
+
else if (config.auth)
|
|
401
|
+
user = (await config.auth.resolve(req, { db: db, url })) ?? null;
|
|
402
|
+
const level = opts.auth ?? "user";
|
|
403
|
+
if (level !== "public") {
|
|
404
|
+
if (!user)
|
|
405
|
+
throw new InferloError("IL3001", "Sign in required.", { status: 401, fix: "Send valid credentials, e.g. an Authorization: Bearer <token> header." });
|
|
406
|
+
if (level !== "user" && !roles(user).includes(level))
|
|
407
|
+
throw new InferloError("IL3002", `This requires the "${level}" role.`, { status: 403 });
|
|
408
|
+
}
|
|
409
|
+
// Rate limit: counted per user (or IP without one), after auth so failed sign-ins on private routes do not use it up.
|
|
410
|
+
if (opts.rateLimit) {
|
|
411
|
+
const who = (opts.rateLimit.by ?? "user") === "user" && user ? `user:${user.id}` : `ip:${ip}`;
|
|
412
|
+
const hit = await rateLimits.hit(`${def.method} ${def.path} ${who}`, windowMs(opts.rateLimit));
|
|
413
|
+
const limited = rateLimitResult(opts.rateLimit, hit);
|
|
414
|
+
for (const [k, v] of Object.entries(limited.headers))
|
|
415
|
+
headers.set(k, v);
|
|
416
|
+
if (limited.error)
|
|
417
|
+
throw limited.error;
|
|
418
|
+
}
|
|
419
|
+
// Input
|
|
420
|
+
const rawParams = {};
|
|
421
|
+
keys.forEach((key, i) => {
|
|
422
|
+
const v = match.values[i];
|
|
423
|
+
if (v !== undefined)
|
|
424
|
+
rawParams[key] = decodeURIComponent(v);
|
|
425
|
+
});
|
|
426
|
+
const params = opts.params ? parseOrThrow(opts.params, rawParams, "path parameters", true) : rawParams;
|
|
427
|
+
const rawQuery = queryObject(url);
|
|
428
|
+
const query = opts.query ? parseOrThrow(opts.query.strip(), rawQuery, "query string", true) : rawQuery;
|
|
429
|
+
let body;
|
|
430
|
+
if (opts.body) {
|
|
431
|
+
const text = method === "GET" || method === "HEAD" ? "" : await readBody(req, maxBody, timeout.signal);
|
|
432
|
+
const type = req.headers.get("content-type") ?? "";
|
|
433
|
+
let raw = undefined;
|
|
434
|
+
if (text) {
|
|
435
|
+
if (type.includes("application/x-www-form-urlencoded"))
|
|
436
|
+
raw = Object.fromEntries(new URLSearchParams(text));
|
|
437
|
+
else {
|
|
438
|
+
try {
|
|
439
|
+
raw = JSON.parse(text);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
throw new InferloError("IL2003", "Request body is not valid JSON.", { status: 400, fix: "Send JSON with Content-Type: application/json." });
|
|
443
|
+
}
|
|
257
444
|
}
|
|
258
445
|
}
|
|
446
|
+
body = parseOrThrow(opts.body, raw, "request body", type.includes("application/x-www-form-urlencoded"));
|
|
259
447
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
448
|
+
let status = 0;
|
|
449
|
+
const ctx = {
|
|
450
|
+
body,
|
|
451
|
+
query,
|
|
452
|
+
params,
|
|
453
|
+
user,
|
|
454
|
+
get db() {
|
|
455
|
+
// Built on first use: after a timeout, new queries fail with IL2007 instead of running.
|
|
456
|
+
return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal)));
|
|
457
|
+
},
|
|
458
|
+
ai: toolsFor(req, signal),
|
|
459
|
+
signal,
|
|
460
|
+
requestId,
|
|
461
|
+
jobs,
|
|
462
|
+
req,
|
|
463
|
+
url,
|
|
464
|
+
headers: req.headers,
|
|
465
|
+
state,
|
|
466
|
+
status: (code) => void (status = code),
|
|
467
|
+
header: (name, value) => headers.set(name, value),
|
|
468
|
+
waitUntil: (p) => void pending.push(p.catch((e) => logger.error("waitUntil task failed", e, { requestId }))),
|
|
469
|
+
};
|
|
470
|
+
const result = await def.handler(ctx);
|
|
471
|
+
if (result instanceof Response) {
|
|
472
|
+
headers.forEach((v, k) => {
|
|
473
|
+
if (!result.headers.has(k))
|
|
474
|
+
result.headers.set(k, v);
|
|
475
|
+
});
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
if (result === undefined)
|
|
479
|
+
return new Response(null, { status: status || 204, headers });
|
|
480
|
+
const response = json(result, status || 200, headers);
|
|
481
|
+
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
277
482
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
483
|
+
if (limit === false)
|
|
484
|
+
return await run();
|
|
485
|
+
let timer;
|
|
486
|
+
const running = run();
|
|
487
|
+
// After a timeout the handler keeps running; its later result or error is dropped.
|
|
488
|
+
running.catch(() => { });
|
|
489
|
+
try {
|
|
490
|
+
return await Promise.race([
|
|
491
|
+
running,
|
|
492
|
+
new Promise((_, reject) => {
|
|
493
|
+
timer = setTimeout(() => {
|
|
494
|
+
const error = new InferloError("IL2007", `The request took longer than ${limit} ms.`, {
|
|
495
|
+
status: 503,
|
|
496
|
+
expose: true,
|
|
497
|
+
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.`,
|
|
498
|
+
});
|
|
499
|
+
timeout.abort(error);
|
|
500
|
+
reject(error);
|
|
501
|
+
}, limit);
|
|
502
|
+
}),
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
finally {
|
|
506
|
+
clearTimeout(timer);
|
|
285
507
|
}
|
|
286
|
-
if (result === undefined)
|
|
287
|
-
return new Response(null, { status: status || 204, headers });
|
|
288
|
-
const response = json(result, status || 200, headers);
|
|
289
|
-
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
|
|
290
508
|
}
|
|
291
509
|
catch (error) {
|
|
292
|
-
return errorResponse(error, headers, req);
|
|
510
|
+
return errorResponse(error, headers, req, requestId);
|
|
293
511
|
}
|
|
294
512
|
};
|
|
513
|
+
// Shutdown state: closing makes /ready answer 503, refusing (after delayMs) answers new requests with 503.
|
|
514
|
+
let closing = false;
|
|
515
|
+
let refusing = false;
|
|
516
|
+
let closed;
|
|
517
|
+
let inFlight = 0;
|
|
518
|
+
const tasks = new Set();
|
|
519
|
+
const servers = new Set();
|
|
520
|
+
let idle = [];
|
|
521
|
+
const settle = () => {
|
|
522
|
+
if (inFlight > 0 || tasks.size > 0)
|
|
523
|
+
return;
|
|
524
|
+
for (const resolve of idle)
|
|
525
|
+
resolve();
|
|
526
|
+
idle = [];
|
|
527
|
+
};
|
|
528
|
+
const whenIdle = () => new Promise((resolve) => {
|
|
529
|
+
idle.push(resolve);
|
|
530
|
+
settle();
|
|
531
|
+
});
|
|
532
|
+
/** Resolves true when promise settled before ms passed, false otherwise. */
|
|
533
|
+
const within = async (promise, ms) => {
|
|
534
|
+
let timer;
|
|
535
|
+
try {
|
|
536
|
+
return await Promise.race([promise.then(() => true, () => true), new Promise((resolve) => (timer = setTimeout(() => resolve(false), ms)))]);
|
|
537
|
+
}
|
|
538
|
+
finally {
|
|
539
|
+
clearTimeout(timer);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
async function handle(req, rest) {
|
|
543
|
+
const started = performance.now();
|
|
544
|
+
const url = new URL(req.url);
|
|
545
|
+
// Behind a trusted proxy, keep the id it sent so log entries line up across services.
|
|
546
|
+
const incoming = config.trustProxy ? req.headers.get("x-request-id") : null;
|
|
547
|
+
const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
|
|
548
|
+
const ip = clientIp(req, Boolean(config.trustProxy), rest[0]);
|
|
549
|
+
if (ip !== "unknown")
|
|
550
|
+
clientIps.set(req, ip);
|
|
551
|
+
const state = {};
|
|
552
|
+
const pending = [];
|
|
553
|
+
const chain = [...(config.middleware ?? [])];
|
|
554
|
+
// The matched route pattern, for metrics ("unmatched" for 404s and responses from middleware).
|
|
555
|
+
const seen = { route: "unmatched" };
|
|
556
|
+
const run = (i) => {
|
|
557
|
+
const mw = chain[i];
|
|
558
|
+
if (!mw)
|
|
559
|
+
return dispatch(req, url, state, pending, ip, requestId, seen);
|
|
560
|
+
return mw({ req, url, state, requestId }, () => run(i + 1));
|
|
561
|
+
};
|
|
562
|
+
let response;
|
|
563
|
+
try {
|
|
564
|
+
response = await run(0);
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
response = errorResponse(error, new Headers(), req, requestId);
|
|
568
|
+
}
|
|
569
|
+
response = withHeader(response, "x-request-id", requestId);
|
|
570
|
+
// Health and metrics endpoints are called every few seconds by load balancers and scrapers: not logged or counted.
|
|
571
|
+
const probe = url.pathname === health?.path || url.pathname === health?.readyPath || url.pathname === metricsConfig?.path;
|
|
572
|
+
if (!probe) {
|
|
573
|
+
const elapsed = performance.now() - started;
|
|
574
|
+
const method = req.method.toUpperCase();
|
|
575
|
+
metrics?.request(method, seen.route, response.status, elapsed / 1000);
|
|
576
|
+
if (logging.requests) {
|
|
577
|
+
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 });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (secureHeaders.length) {
|
|
582
|
+
const https = url.protocol === "https:" || (Boolean(config.trustProxy) && req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https");
|
|
583
|
+
response = applySecureHeaders(response, secureHeaders, https);
|
|
584
|
+
}
|
|
585
|
+
if (pending.length) {
|
|
586
|
+
const all = Promise.all(pending);
|
|
587
|
+
const execution = rest[1];
|
|
588
|
+
if (execution?.waitUntil)
|
|
589
|
+
execution.waitUntil(all);
|
|
590
|
+
else {
|
|
591
|
+
// Kept until it settles, so app.close() waits for it.
|
|
592
|
+
tasks.add(all);
|
|
593
|
+
void all.finally(() => {
|
|
594
|
+
tasks.delete(all);
|
|
595
|
+
settle();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return response;
|
|
600
|
+
}
|
|
295
601
|
const app = {
|
|
296
602
|
name: config.name ?? "inferlo-app",
|
|
297
603
|
version: config.version ?? "0.0.0",
|
|
@@ -299,33 +605,21 @@ export function createApp(config = {}) {
|
|
|
299
605
|
models,
|
|
300
606
|
db,
|
|
301
607
|
ai,
|
|
608
|
+
jobs,
|
|
609
|
+
get closing() {
|
|
610
|
+
return closing;
|
|
611
|
+
},
|
|
302
612
|
async fetch(req, ...rest) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const pending = [];
|
|
306
|
-
const chain = [...(config.middleware ?? [])];
|
|
307
|
-
const run = (i) => {
|
|
308
|
-
const mw = chain[i];
|
|
309
|
-
if (!mw)
|
|
310
|
-
return dispatch(req, url, state, pending);
|
|
311
|
-
return mw({ req, url, state }, () => run(i + 1));
|
|
312
|
-
};
|
|
313
|
-
let response;
|
|
613
|
+
inFlight++;
|
|
614
|
+
metrics?.inFlight(1);
|
|
314
615
|
try {
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
catch (error) {
|
|
318
|
-
response = errorResponse(error, new Headers(), req);
|
|
616
|
+
return await handle(req, rest);
|
|
319
617
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
execution.waitUntil(all);
|
|
325
|
-
else
|
|
326
|
-
void all;
|
|
618
|
+
finally {
|
|
619
|
+
inFlight--;
|
|
620
|
+
metrics?.inFlight(-1);
|
|
621
|
+
settle();
|
|
327
622
|
}
|
|
328
|
-
return response;
|
|
329
623
|
},
|
|
330
624
|
async request(path, options = {}) {
|
|
331
625
|
const url = new URL(path, "http://inferlo.test");
|
|
@@ -345,10 +639,74 @@ export function createApp(config = {}) {
|
|
|
345
639
|
},
|
|
346
640
|
async listen(port = 3000, hostname = "0.0.0.0") {
|
|
347
641
|
const { serve } = await import("./serve.js");
|
|
348
|
-
|
|
642
|
+
const server = await serve(app, { port, hostname });
|
|
643
|
+
servers.add(server);
|
|
644
|
+
return {
|
|
645
|
+
port: server.port,
|
|
646
|
+
close: async () => {
|
|
647
|
+
servers.delete(server);
|
|
648
|
+
await server.close();
|
|
649
|
+
},
|
|
650
|
+
};
|
|
651
|
+
},
|
|
652
|
+
async checkReady() {
|
|
653
|
+
if (closing)
|
|
654
|
+
return { status: "not_ready", checks: { shutdown: { status: "error", durationMs: 0, error: "The app is shutting down." } } };
|
|
655
|
+
const names = Object.keys(readinessChecks);
|
|
656
|
+
const results = await Promise.all(names.map((name) => runCheck(name, readinessChecks[name])));
|
|
657
|
+
const checks = {};
|
|
658
|
+
names.forEach((name, i) => (checks[name] = results[i]));
|
|
659
|
+
return { status: results.every((r) => r.status === "ok") ? "ready" : "not_ready", checks };
|
|
660
|
+
},
|
|
661
|
+
close(options = {}) {
|
|
662
|
+
if (closed)
|
|
663
|
+
return closed;
|
|
664
|
+
const delayMs = options.delayMs ?? 0;
|
|
665
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
666
|
+
for (const [key, value] of [["delayMs", delayMs], ["timeoutMs", timeoutMs]]) {
|
|
667
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
668
|
+
throw new InferloError("IL6015", `app.close({ ${key} }) must be a number of milliseconds of 0 or more, got ${String(value)}.`, {
|
|
669
|
+
fix: "Use app.close({ delayMs: 5000, timeoutMs: 10000 }) or app.close().",
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
closing = true;
|
|
674
|
+
closed = (async () => {
|
|
675
|
+
if (delayMs > 0)
|
|
676
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
677
|
+
refusing = true;
|
|
678
|
+
const deadline = Date.now() + timeoutMs;
|
|
679
|
+
let workersStopped = false;
|
|
680
|
+
const drained = Promise.all([whenIdle(), stopWorkers(jobs).then(() => void (workersStopped = true))]);
|
|
681
|
+
if (!(await within(drained, timeoutMs))) {
|
|
682
|
+
const left = [`${inFlight} request(s)`, `${tasks.size} waitUntil task(s)`];
|
|
683
|
+
if (!workersStopped)
|
|
684
|
+
left.push("the running job");
|
|
685
|
+
logger.error(`app.close(): stopped waiting after ${timeoutMs} ms with ${left.join(", ")} still running`);
|
|
686
|
+
}
|
|
687
|
+
const { forceClosers } = servers.size ? await import("./serve.js") : { forceClosers: undefined };
|
|
688
|
+
await Promise.all([...servers].map(async (server) => {
|
|
689
|
+
servers.delete(server);
|
|
690
|
+
if (!(await within(server.close(), Math.max(0, deadline - Date.now()))))
|
|
691
|
+
forceClosers?.get(server)?.();
|
|
692
|
+
}));
|
|
693
|
+
await db.$close();
|
|
694
|
+
})();
|
|
695
|
+
return closed;
|
|
349
696
|
},
|
|
350
697
|
describe() {
|
|
351
|
-
return describeApp(app, {
|
|
698
|
+
return describeApp(app, {
|
|
699
|
+
auth: Boolean(config.auth),
|
|
700
|
+
openapi: openapiPath,
|
|
701
|
+
mcp: mcpPath,
|
|
702
|
+
health: health?.path ?? false,
|
|
703
|
+
ready: health?.readyPath ?? false,
|
|
704
|
+
metrics: metricsConfig?.path ?? false,
|
|
705
|
+
secureHeaders: secureHeaders.map(([name]) => name),
|
|
706
|
+
requestTimeoutMs,
|
|
707
|
+
logging: { format: config.logger ? "custom" : logging.format, requests: logging.requests },
|
|
708
|
+
description: config.description,
|
|
709
|
+
});
|
|
352
710
|
},
|
|
353
711
|
openapi() {
|
|
354
712
|
return buildOpenAPI(app, { description: config.description, auth: Boolean(config.auth) });
|
|
@@ -356,6 +714,46 @@ export function createApp(config = {}) {
|
|
|
356
714
|
};
|
|
357
715
|
return app;
|
|
358
716
|
}
|
|
717
|
+
function resolveHealth(option, routes, taken) {
|
|
718
|
+
if (option === undefined || option === false)
|
|
719
|
+
return undefined;
|
|
720
|
+
const bad = (message, fix) => new InferloError("IL6014", message, { fix });
|
|
721
|
+
if (option !== true && (typeof option !== "object" || option === null))
|
|
722
|
+
throw bad("health must be true or an object.", "Use createApp({ health: true }) or createApp({ health: { checks: { ... } } }).");
|
|
723
|
+
const o = option === true ? {} : option;
|
|
724
|
+
const resolved = { path: o.path ?? "/health", readyPath: o.readyPath ?? "/ready", timeoutMs: o.timeoutMs ?? 5000, checks: o.checks ?? {} };
|
|
725
|
+
for (const [key, value] of [["path", resolved.path], ["readyPath", resolved.readyPath]]) {
|
|
726
|
+
if (typeof value !== "string" || !value.startsWith("/"))
|
|
727
|
+
throw bad(`health.${key} must be a path starting with "/", got ${JSON.stringify(value)}.`, `Use health: { ${key}: "/${String(value).replace(/^\/+/, "")}" }.`);
|
|
728
|
+
}
|
|
729
|
+
if (resolved.path === resolved.readyPath)
|
|
730
|
+
throw bad(`health.path and health.readyPath are both "${resolved.path}".`, 'Use different paths, for example "/health" and "/ready".');
|
|
731
|
+
if (!Number.isFinite(resolved.timeoutMs) || resolved.timeoutMs <= 0)
|
|
732
|
+
throw bad(`health.timeoutMs must be a positive number of milliseconds, got ${String(resolved.timeoutMs)}.`, "Use health: { timeoutMs: 5000 }.");
|
|
733
|
+
if (typeof resolved.checks !== "object" || resolved.checks === null)
|
|
734
|
+
throw bad("health.checks must be an object of functions.", "Use health: { checks: { redis: () => redis.ping() } }.");
|
|
735
|
+
for (const [name, check] of Object.entries(resolved.checks)) {
|
|
736
|
+
if (name === "database" || name === "ai")
|
|
737
|
+
throw bad(`health.checks.${name} is built in.`, `Give your check another name, for example "${name}Replica".`);
|
|
738
|
+
if (typeof check !== "function")
|
|
739
|
+
throw bad(`health.checks.${name} must be a function.`, `Use health: { checks: { ${name}: async () => { ... } } }; throw when it is not ready.`);
|
|
740
|
+
}
|
|
741
|
+
for (const path of [resolved.path, resolved.readyPath]) {
|
|
742
|
+
if (taken.includes(path))
|
|
743
|
+
throw bad(`The health endpoint ${path} is also the OpenAPI or MCP path.`, "Choose another health.path or health.readyPath.");
|
|
744
|
+
const route = routes.find((r) => r.method === "GET" && r.path.replace(/\/+$/, "") === path.replace(/\/+$/, ""));
|
|
745
|
+
if (route)
|
|
746
|
+
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.`);
|
|
747
|
+
}
|
|
748
|
+
return resolved;
|
|
749
|
+
}
|
|
750
|
+
function checkTimeout(value, where) {
|
|
751
|
+
if (value === false || (typeof value === "number" && Number.isFinite(value) && value > 0))
|
|
752
|
+
return value;
|
|
753
|
+
throw new InferloError("IL6017", `${where} must be a positive number of milliseconds or false, got ${typeof value === "string" ? JSON.stringify(value) : String(value)}.`, {
|
|
754
|
+
fix: "Use a number such as 30000 (30 seconds), or false for no timeout.",
|
|
755
|
+
});
|
|
756
|
+
}
|
|
359
757
|
function parseOrThrow(schema, value, where, coerce) {
|
|
360
758
|
const result = schema.safeParse(value, { coerce });
|
|
361
759
|
if (!result.ok)
|