@browserlessapi/inferlo 0.6.0 → 0.8.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 +9 -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 +37 -3
- package/dist/ai/index.js +96 -7
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/public.d.ts +5 -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/app.d.ts +55 -4
- package/dist/app.js +439 -39
- 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 +8 -1
- package/dist/audit.js +9 -2
- package/dist/audit.js.map +1 -1
- package/dist/auth.d.ts +1 -0
- package/dist/auth.js +1 -0
- package/dist/auth.js.map +1 -1
- package/dist/authjs.d.ts +70 -0
- package/dist/authjs.js +94 -0
- package/dist/authjs.js.map +1 -0
- package/dist/azure.d.ts +66 -0
- package/dist/azure.js +119 -0
- package/dist/azure.js.map +1 -0
- package/dist/better-auth.d.ts +89 -0
- package/dist/better-auth.js +75 -0
- package/dist/better-auth.js.map +1 -0
- package/dist/cache.d.ts +13 -9
- package/dist/cache.js +11 -9
- package/dist/cache.js.map +1 -1
- package/dist/cli/main.js +5 -5
- package/dist/cli/main.js.map +1 -1
- package/dist/client-ip.d.ts +41 -1
- package/dist/client-ip.js +85 -1
- package/dist/client-ip.js.map +1 -1
- package/dist/cross-site.d.ts +14 -0
- package/dist/cross-site.js +56 -0
- package/dist/cross-site.js.map +1 -0
- package/dist/db/memory.js +2 -1
- package/dist/db/memory.js.map +1 -1
- package/dist/db/postgres.js +23 -6
- 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 +23 -5
- package/dist/describe.js +20 -2
- package/dist/describe.js.map +1 -1
- package/dist/disk-storage.d.ts +6 -0
- package/dist/disk-storage.js +96 -0
- package/dist/disk-storage.js.map +1 -0
- package/dist/events.d.ts +134 -0
- package/dist/events.js +196 -0
- package/dist/events.js.map +1 -0
- package/dist/files.d.ts +23 -0
- package/dist/files.js +168 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +26 -8
- package/dist/index.js +10 -2
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +56 -10
- package/dist/jobs.js +71 -13
- package/dist/jobs.js.map +1 -1
- package/dist/jwt.d.ts +83 -0
- package/dist/jwt.js +312 -0
- package/dist/jwt.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/model.d.ts +23 -6
- package/dist/model.js +41 -7
- package/dist/model.js.map +1 -1
- package/dist/oidc.d.ts +70 -0
- package/dist/oidc.js +379 -0
- package/dist/oidc.js.map +1 -0
- package/dist/openapi.js +17 -2
- package/dist/openapi.js.map +1 -1
- package/dist/passport.d.ts +54 -0
- package/dist/passport.js +68 -0
- package/dist/passport.js.map +1 -0
- package/dist/postgres-js.d.ts +39 -0
- package/dist/postgres-js.js +71 -0
- package/dist/postgres-js.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/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-sockets.d.ts +24 -0
- package/dist/redis-sockets.js +75 -0
- package/dist/redis-sockets.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/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 +117 -3
- package/dist/route.js +27 -0
- package/dist/route.js.map +1 -1
- package/dist/schema.d.ts +23 -3
- package/dist/schema.js +70 -0
- package/dist/schema.js.map +1 -1
- package/dist/serve.d.ts +24 -1
- package/dist/serve.js +200 -8
- 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 +2 -0
- package/dist/sessions.js +36 -4
- package/dist/sessions.js.map +1 -1
- package/dist/sockets.d.ts +58 -0
- package/dist/sockets.js +111 -0
- package/dist/sockets.js.map +1 -0
- package/dist/storage.d.ts +47 -0
- package/dist/storage.js +123 -0
- package/dist/storage.js.map +1 -0
- package/dist/tracing.d.ts +7 -3
- package/dist/tracing.js +8 -2
- package/dist/tracing.js.map +1 -1
- package/dist/websocket.d.ts +81 -0
- package/dist/websocket.js +251 -0
- package/dist/websocket.js.map +1 -0
- package/llms.txt +2 -0
- package/package.json +77 -5
- package/templates/app/AGENTS.md +9 -5
- package/templates/app/test/notes.test.ts +2 -0
package/dist/app.js
CHANGED
|
@@ -5,17 +5,28 @@
|
|
|
5
5
|
import { bindTools, createAI } from "./ai/index.js";
|
|
6
6
|
import { memory } from "./db/memory.js";
|
|
7
7
|
import { createAudit } from "./audit.js";
|
|
8
|
+
import { readEnv } from "./env.js";
|
|
9
|
+
import { checkStorageStore, createStorage } from "./storage.js";
|
|
8
10
|
import { checkRouteCache, createCache, databaseCacheStore, etagFor, etagMatches, memoryCacheStore, resolveCacheConfig, routeCacheKey } from "./cache.js";
|
|
9
|
-
import { bindDb, createDb, guardAdapter } from "./db/table.js";
|
|
11
|
+
import { bindDb, checkTenantId, createDb, guardAdapter } from "./db/table.js";
|
|
10
12
|
import { describeApp } from "./describe.js";
|
|
11
13
|
import { InferloError, didYouMean, suggest } from "./errors.js";
|
|
12
14
|
import { callTool, handleMcp, toolDefinition } from "./mcp.js";
|
|
13
15
|
import { buildOpenAPI } from "./openapi.js";
|
|
14
16
|
import { collectRoutes } from "./route.js";
|
|
17
|
+
import { createSocketHub } from "./sockets.js";
|
|
18
|
+
import { checkSocketsConfig, redisSocketRelay } from "./redis-sockets.js";
|
|
19
|
+
import { bunUpgrader, denoUpgrader, upgraders, upgrades, validKey } from "./websocket.js";
|
|
20
|
+
import { checkRedisClient, pingRedis, redisCacheStore, redisMissing, redisPrefix, redisRateLimitStore } from "./redis.js";
|
|
15
21
|
import { checkRateLimit, databaseRateLimitStore, memoryRateLimitStore, rateLimitResult, windowMs } from "./rate-limit.js";
|
|
16
|
-
import { clientIp, clientIps } from "./client-ip.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
22
|
+
import { clientIp, clientIps, clientSignals, keepEventStreamOpen, platformWaitUntil } from "./client-ip.js";
|
|
23
|
+
import { redisJobStore } from "./redis-jobs.js";
|
|
24
|
+
import { knownBodies } from "./response-body.js";
|
|
25
|
+
import { checkFileContents, checkFileFields, fileBudget, isMultipart, readMultipart } from "./files.js";
|
|
26
|
+
import { collectCrons, collectJobs, collectSubscriptions, createJobs, jobStoreFor, stopWorkers, withTenant } from "./jobs.js";
|
|
27
|
+
import { checkEventsConfig, invalidTransport } from "./events.js";
|
|
28
|
+
import { redisEventTransport } from "./redis-events.js";
|
|
29
|
+
import { Schema, ValidationError } from "./schema.js";
|
|
19
30
|
import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
|
|
20
31
|
import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
|
|
21
32
|
import { clientTracing, jobTracing, requestTracing, traceAdapter, traceFields } from "./tracing.js";
|
|
@@ -62,11 +73,14 @@ function corsHeaders(config, origin) {
|
|
|
62
73
|
return h;
|
|
63
74
|
}
|
|
64
75
|
async function readBody(req, limit, signal) {
|
|
76
|
+
return new TextDecoder().decode(await readBytes(req, limit, signal));
|
|
77
|
+
}
|
|
78
|
+
async function readBytes(req, limit, signal) {
|
|
65
79
|
const length = Number(req.headers.get("content-length") ?? "0");
|
|
66
80
|
if (length > limit)
|
|
67
81
|
throw new InferloError("IL2004", `Request body is larger than ${limit} bytes.`, { status: 413 });
|
|
68
82
|
if (!req.body)
|
|
69
|
-
return
|
|
83
|
+
return new Uint8Array(0);
|
|
70
84
|
const reader = req.body.getReader();
|
|
71
85
|
// Stop reading a slow body once the request timed out.
|
|
72
86
|
signal?.addEventListener("abort", () => void reader.cancel().catch(() => { }), { once: true });
|
|
@@ -89,7 +103,7 @@ async function readBody(req, limit, signal) {
|
|
|
89
103
|
all.set(c, offset);
|
|
90
104
|
offset += c.byteLength;
|
|
91
105
|
}
|
|
92
|
-
return
|
|
106
|
+
return all;
|
|
93
107
|
}
|
|
94
108
|
function queryObject(url) {
|
|
95
109
|
const out = {};
|
|
@@ -115,7 +129,10 @@ function withHeader(response, name, value) {
|
|
|
115
129
|
}
|
|
116
130
|
function json(data, status, headers) {
|
|
117
131
|
headers.set("content-type", "application/json; charset=utf-8");
|
|
118
|
-
|
|
132
|
+
const text = JSON.stringify(data);
|
|
133
|
+
const response = new Response(text, { status, headers });
|
|
134
|
+
knownBodies.set(response, text);
|
|
135
|
+
return response;
|
|
119
136
|
}
|
|
120
137
|
export function createApp(config = {}) {
|
|
121
138
|
// Everything the app logs goes through the redactor: credentials and secrets never reach the logs.
|
|
@@ -171,6 +188,14 @@ export function createApp(config = {}) {
|
|
|
171
188
|
}
|
|
172
189
|
names.set(r.name, r);
|
|
173
190
|
}
|
|
191
|
+
// Routes with t.file() fields take multipart/form-data bodies up to maxBodySize plus their files' maxBytes.
|
|
192
|
+
const fileBudgets = new Map();
|
|
193
|
+
for (const r of routes) {
|
|
194
|
+
checkFileFields(r);
|
|
195
|
+
const budget = fileBudget(r.options.body);
|
|
196
|
+
if (budget > 0)
|
|
197
|
+
fileBudgets.set(r, budget);
|
|
198
|
+
}
|
|
174
199
|
const needsAuth = routes.filter((r) => (r.options.auth ?? "user") !== "public");
|
|
175
200
|
if (needsAuth.length && !config.auth) {
|
|
176
201
|
throw new InferloError("IL3003", `${needsAuth.length} route(s) require a signed-in user, but createApp has no auth configured: ${needsAuth.map((r) => `${r.method} ${r.path}`).join(", ")}.`, {
|
|
@@ -197,10 +222,20 @@ export function createApp(config = {}) {
|
|
|
197
222
|
for (const r of routes)
|
|
198
223
|
if (r.options.rateLimit !== undefined)
|
|
199
224
|
checkRateLimit(r.options.rateLimit, `${r.method} ${r.path}`);
|
|
225
|
+
for (const r of routes) {
|
|
226
|
+
if (!r.websocket)
|
|
227
|
+
continue;
|
|
228
|
+
const message = r.options.message;
|
|
229
|
+
if (message !== undefined && !(message instanceof Schema)) {
|
|
230
|
+
throw new InferloError("IL6035", `The WebSocket route ${r.path} has a message option that is not a schema.`, { fix: 'Pass a schema: route.ws("/chat", { message: t.object({ text: t.string() }) }).' });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
200
233
|
for (const r of routes)
|
|
201
234
|
if (r.options.cache !== undefined)
|
|
202
235
|
checkRouteCache(r.options.cache, r.method, `${r.method} ${r.path}`);
|
|
203
236
|
const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
|
|
237
|
+
const validateResponses = resolveValidateResponses(config.validateResponses);
|
|
238
|
+
const warnedResponses = new Set();
|
|
204
239
|
for (const r of routes)
|
|
205
240
|
if (r.options.timeoutMs !== undefined)
|
|
206
241
|
checkTimeout(r.options.timeoutMs, `${r.method} ${r.path} { timeoutMs }`);
|
|
@@ -211,13 +246,16 @@ export function createApp(config = {}) {
|
|
|
211
246
|
deprecations.set(r, resolved);
|
|
212
247
|
}
|
|
213
248
|
const storeName = config.rateLimit?.store ?? "memory";
|
|
214
|
-
if (storeName !== "memory" && storeName !== "database") {
|
|
215
|
-
throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }) or { store: "
|
|
249
|
+
if (storeName !== "memory" && storeName !== "database" && storeName !== "redis") {
|
|
250
|
+
throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }), { store: "database" } or { store: "redis" }.' });
|
|
216
251
|
}
|
|
252
|
+
const redis = checkRedisClient(config.redis);
|
|
253
|
+
if (storeName === "redis" && !redis)
|
|
254
|
+
throw redisMissing("rateLimit");
|
|
217
255
|
const openapiPath = config.openapi === undefined ? "/openapi.json" : config.openapi;
|
|
218
256
|
const hasTools = routes.some((r) => r.options.tool);
|
|
219
257
|
const mcpPath = config.mcp === undefined ? (hasTools ? "/mcp" : false) : config.mcp;
|
|
220
|
-
const health = resolveHealth(config.health, routes, [openapiPath, mcpPath]);
|
|
258
|
+
const health = resolveHealth(config.health, routes, [openapiPath, mcpPath], redis ? ["redis"] : []);
|
|
221
259
|
// apiVersion("v1", ...) routes get their own OpenAPI document at /v1/openapi.json.
|
|
222
260
|
const versions = versionsOf(routes);
|
|
223
261
|
const versionDocs = new Map(openapiPath ? versions.map((v) => [versionDocPath(v, openapiPath), v]) : []);
|
|
@@ -238,9 +276,13 @@ export function createApp(config = {}) {
|
|
|
238
276
|
const appTracing = tracing ? { ...config.tracing, tracerName: config.tracing.tracerName ?? config.name ?? "inferlo-app" } : undefined;
|
|
239
277
|
const adapter = appTracing ? traceAdapter(config.db ?? memory(), clientTracing(appTracing, "inferlo-app", config.version ?? "0.0.0")) : (config.db ?? memory());
|
|
240
278
|
const db = createDb(models, adapter);
|
|
241
|
-
const rateLimits = storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
|
|
279
|
+
const rateLimits = storeName === "redis" ? redisRateLimitStore(redis, redisPrefix(config.name)) : storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
|
|
242
280
|
const cacheConfig = resolveCacheConfig(config.cache);
|
|
243
|
-
|
|
281
|
+
if (cacheConfig.store === "redis" && !redis)
|
|
282
|
+
throw redisMissing("cache");
|
|
283
|
+
const cacheStore = cacheConfig.store === "redis" ? redisCacheStore(redis, redisPrefix(config.name)) : cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries);
|
|
284
|
+
const cache = createCache(cacheStore, cacheConfig.store);
|
|
285
|
+
const storage = createStorage(checkStorageStore(config.storage));
|
|
244
286
|
const metrics = metricsConfig
|
|
245
287
|
? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
|
|
246
288
|
: undefined;
|
|
@@ -260,17 +302,39 @@ export function createApp(config = {}) {
|
|
|
260
302
|
jobModule.set(j.name, m.name);
|
|
261
303
|
const moduleInfo = { modules, routeModule, modelOwner, jobModule };
|
|
262
304
|
const jobSources = [...(config.jobs ?? []), ...modules.flatMap((m) => m.jobs)];
|
|
305
|
+
const transportOption = checkEventsConfig(config.events);
|
|
306
|
+
let eventTransport;
|
|
307
|
+
if (transportOption === "redis") {
|
|
308
|
+
if (!redis)
|
|
309
|
+
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" } }).' });
|
|
310
|
+
// The app name is the consumer group: two services with one name would split each other's events.
|
|
311
|
+
if (!config.name)
|
|
312
|
+
throw invalidTransport('events transport "redis" needs createApp({ name }): each service reads events as its own consumer group, named after the app.');
|
|
313
|
+
eventTransport = redisEventTransport(redis, { group: config.name });
|
|
314
|
+
}
|
|
315
|
+
else
|
|
316
|
+
eventTransport = transportOption;
|
|
317
|
+
const socketSubscriber = checkSocketsConfig(config.sockets, redis, config.name);
|
|
318
|
+
const socketHub = createSocketHub(socketSubscriber ? redisSocketRelay(redis, socketSubscriber, `${redisPrefix(config.name)}sockets`, (message, error) => logger.error(`WebSocket broadcasts: ${message}`, error ?? "")) : undefined);
|
|
263
319
|
const jobs = createJobs(collectJobs(jobSources), {
|
|
264
320
|
db: db,
|
|
265
321
|
ai,
|
|
266
322
|
cache,
|
|
267
|
-
|
|
268
|
-
|
|
323
|
+
storage,
|
|
324
|
+
sockets: socketHub,
|
|
325
|
+
audit: (run) => createAudit(models, redactor, () => db, { userId: null, tenantId: run.tenantId, source: `job ${run.name}` }),
|
|
326
|
+
store: jobStoreFor(config.jobQueue, adapter, () => {
|
|
327
|
+
if (!redis)
|
|
328
|
+
throw redisMissing("jobQueue");
|
|
329
|
+
return redisJobStore(redis, redisPrefix(config.name));
|
|
330
|
+
}),
|
|
269
331
|
queue: config.jobQueue?.store ?? "memory",
|
|
270
332
|
logger,
|
|
271
333
|
onRun: metrics ? (name, outcome, ms) => metrics.job(name, outcome, ms / 1000) : undefined,
|
|
272
334
|
tracing: appTracing ? jobTracing(appTracing, "inferlo-app", config.version ?? "0.0.0") : undefined,
|
|
273
|
-
|
|
335
|
+
eventTransport,
|
|
336
|
+
}, collectCrons(jobSources), collectSubscriptions(jobSources));
|
|
337
|
+
const events = jobs.events;
|
|
274
338
|
const maxBody = config.maxBodySize ?? 1024 * 1024;
|
|
275
339
|
const secureHeaders = resolveSecureHeaders(config.secureHeaders);
|
|
276
340
|
const cors = config.cors === true ? { origin: "*" } : config.cors || undefined;
|
|
@@ -294,6 +358,8 @@ export function createApp(config = {}) {
|
|
|
294
358
|
}
|
|
295
359
|
};
|
|
296
360
|
}
|
|
361
|
+
if (redis)
|
|
362
|
+
readinessChecks.redis = () => pingRedis(redis);
|
|
297
363
|
Object.assign(readinessChecks, health?.checks);
|
|
298
364
|
const runCheck = async (name, check) => {
|
|
299
365
|
const started = Date.now();
|
|
@@ -330,6 +396,11 @@ export function createApp(config = {}) {
|
|
|
330
396
|
fix: "Pass routes that are registered in createApp({ routes }) to ai.generate({ tools }).",
|
|
331
397
|
});
|
|
332
398
|
}
|
|
399
|
+
if (fileBudgets.has(found)) {
|
|
400
|
+
throw new InferloError("IL1004", `Route ${found.method} ${found.path} takes files, so it cannot be an AI tool (tools send JSON).`, {
|
|
401
|
+
fix: "Pass routes without t.file() fields to ai.generate({ tools }).",
|
|
402
|
+
});
|
|
403
|
+
}
|
|
333
404
|
return toolDefinition(found);
|
|
334
405
|
};
|
|
335
406
|
const describeTool = (route) => {
|
|
@@ -349,6 +420,13 @@ export function createApp(config = {}) {
|
|
|
349
420
|
}
|
|
350
421
|
return { name: tool.name, status: response.status, output, text: text || String(response.status) };
|
|
351
422
|
}, describeTool, signal);
|
|
423
|
+
const tenantScope = (user) => {
|
|
424
|
+
if (!user)
|
|
425
|
+
return "none";
|
|
426
|
+
const id = config.auth?.tenant ? config.auth.tenant(user) : user.tenantId;
|
|
427
|
+
return id === null || id === undefined ? "none" : { tenant: checkTenantId(id) };
|
|
428
|
+
};
|
|
429
|
+
const tenantOf = (scope) => (typeof scope === "object" ? scope.tenant : null);
|
|
352
430
|
const roles = (user) => config.auth?.roles?.(user) ?? (user.roles ?? []);
|
|
353
431
|
const errorResponse = (error, headers, req, requestId) => {
|
|
354
432
|
const span = requestSpans.get(req);
|
|
@@ -367,7 +445,218 @@ export function createApp(config = {}) {
|
|
|
367
445
|
const message = config.exposeErrors && error instanceof Error ? redactor.text(error.message) : "Internal server error";
|
|
368
446
|
return json({ error: { code: "IL9000", message, docs: new InferloError("IL9000", "").docs } }, 500, headers);
|
|
369
447
|
};
|
|
370
|
-
|
|
448
|
+
// Rate limit counts are kept per route and per user, tenant or IP. by "tenant" counts users without a tenant one by
|
|
449
|
+
// one; by "user" and "tenant" count requests without a user by IP.
|
|
450
|
+
const rateLimitKey = (def, user, ip) => {
|
|
451
|
+
const by = def.options.rateLimit.by ?? "user";
|
|
452
|
+
const scope = by === "tenant" && user ? tenantScope(user) : "none";
|
|
453
|
+
const who = typeof scope === "object" ? `tenant:${scope.tenant}` : by !== "ip" && user ? `user:${user.id}` : `ip:${ip}`;
|
|
454
|
+
return `${def.method} ${def.path} ${who}`;
|
|
455
|
+
};
|
|
456
|
+
// Open WebSockets, closed with 1001 by app.close().
|
|
457
|
+
const sockets = new Set();
|
|
458
|
+
const encoder = new TextEncoder();
|
|
459
|
+
/** The JSON error message a socket gets when one of its messages fails (the same body as an HTTP error answer). */
|
|
460
|
+
const socketError = (error, def, requestId) => {
|
|
461
|
+
if (error instanceof InferloError) {
|
|
462
|
+
if (error.status >= 500)
|
|
463
|
+
logger.error(`WS ${def.path} message failed: ${error.format()}`, error.cause ?? "", { requestId });
|
|
464
|
+
const body = error.toJSON();
|
|
465
|
+
if (config.exposeErrors && !error.expose)
|
|
466
|
+
body.error.message = redactor.text(error.message);
|
|
467
|
+
return body;
|
|
468
|
+
}
|
|
469
|
+
logger.error(`WS ${def.path} message failed`, error, { requestId });
|
|
470
|
+
const message = config.exposeErrors && error instanceof Error ? redactor.text(error.message) : "Internal server error";
|
|
471
|
+
return { error: { code: "IL9000", message, docs: new InferloError("IL9000", "").docs } };
|
|
472
|
+
};
|
|
473
|
+
/** Checks the upgrade request of a WebSocket route (after auth, permissions, rate limits and input) and accepts it. */
|
|
474
|
+
const acceptSocket = async (def, req, url, headers, rest, conn) => {
|
|
475
|
+
if (req.method.toUpperCase() !== "GET" || req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
|
|
476
|
+
headers.set("upgrade", "websocket");
|
|
477
|
+
throw new InferloError("IL2010", `${def.path} is a WebSocket route: open it with a WebSocket client.`, { status: 426, fix: `Connect with new WebSocket("wss://<host>${def.path}"); plain HTTP requests are not answered.` });
|
|
478
|
+
}
|
|
479
|
+
if (req.headers.get("sec-websocket-version") !== "13" || !validKey(req.headers.get("sec-websocket-key"))) {
|
|
480
|
+
headers.set("sec-websocket-version", "13");
|
|
481
|
+
throw new InferloError("IL2010", "Invalid WebSocket handshake.", { status: 400, fix: "Use a WebSocket client that speaks RFC 6455 (Sec-WebSocket-Version: 13 with a Sec-WebSocket-Key)." });
|
|
482
|
+
}
|
|
483
|
+
// Browsers send cookies with WebSocket upgrades from any page, so only the app's own origin (and the origins
|
|
484
|
+
// allowed by createApp({ cors })) may connect. Clients without an Origin header (servers, CLIs) may.
|
|
485
|
+
const origin = req.headers.get("origin");
|
|
486
|
+
if (origin !== null && !socketOriginAllowed(origin, req, url)) {
|
|
487
|
+
throw new InferloError("IL3006", `Connections from the origin ${origin} are not allowed.`, { status: 403, fix: "Open the socket from a page of the app's own origin, or list the page's origin in createApp({ cors: { origin: [...] } })." });
|
|
488
|
+
}
|
|
489
|
+
const upgrader = upgraders.get(req) ?? denoUpgrader(req, rest[0]) ?? bunUpgrader(req, rest[0]);
|
|
490
|
+
if (!upgrader) {
|
|
491
|
+
throw new InferloError("IL2011", "This server cannot accept WebSocket connections.", {
|
|
492
|
+
status: 501,
|
|
493
|
+
fix: 'Serve the app with app.listen() or serve(app), with Deno.serve(app.fetch) or deno serve on Deno, or with Bun.serve({ fetch: app.fetch, websocket: bunWebSocket }) on Bun. On a Node.js server another framework created, add server.on("upgrade", toNodeUpgradeHandler(app)). app.request() cannot upgrade: test against await app.listen(0).',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
// With sockets: { transport: "redis" }, the first connection subscribes this instance to the app's broadcasts.
|
|
497
|
+
await socketHub.ready();
|
|
498
|
+
const response = await upgrader((transport) => openSocket(def, req, url, transport, conn));
|
|
499
|
+
upgrades.set(req, response ?? null);
|
|
500
|
+
// On Node.js and Bun the server already answered the handshake; this response is never sent.
|
|
501
|
+
return response ?? new Response(null, { status: 200, headers });
|
|
502
|
+
};
|
|
503
|
+
const socketOriginAllowed = (origin, req, url) => {
|
|
504
|
+
try {
|
|
505
|
+
if (new URL(origin).host === (req.headers.get("host") ?? url.host))
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
if (!cors)
|
|
512
|
+
return false;
|
|
513
|
+
return cors.origin === "*" || (Array.isArray(cors.origin) ? cors.origin : [cors.origin]).includes(origin);
|
|
514
|
+
};
|
|
515
|
+
/** One open WebSocket: runs the route's handler for each message, in order. */
|
|
516
|
+
const openSocket = (def, req, url, transport, conn) => {
|
|
517
|
+
const { user, requestId, ip } = conn;
|
|
518
|
+
const opts = def.options;
|
|
519
|
+
const closed = new AbortController();
|
|
520
|
+
let open = true;
|
|
521
|
+
const member = {
|
|
522
|
+
sendText: (text) => {
|
|
523
|
+
if (open)
|
|
524
|
+
transport.send(text);
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
const finish = () => {
|
|
528
|
+
closed.abort();
|
|
529
|
+
sockets.delete(shutdown);
|
|
530
|
+
socketHub.drop(member);
|
|
531
|
+
};
|
|
532
|
+
const end = (code, reason) => {
|
|
533
|
+
if (!open)
|
|
534
|
+
return;
|
|
535
|
+
open = false;
|
|
536
|
+
transport.close(code, reason);
|
|
537
|
+
finish();
|
|
538
|
+
};
|
|
539
|
+
const shutdown = () => end(1001, "Server shutting down");
|
|
540
|
+
sockets.add(shutdown);
|
|
541
|
+
socketHub.open(member);
|
|
542
|
+
const scope = tenantScope(user);
|
|
543
|
+
const socket = {
|
|
544
|
+
id: crypto.randomUUID(),
|
|
545
|
+
send: (data) => {
|
|
546
|
+
if (open)
|
|
547
|
+
transport.send(JSON.stringify(data));
|
|
548
|
+
},
|
|
549
|
+
close: (code = 1000, reason = "") => {
|
|
550
|
+
if (!(code === 1000 || (Number.isInteger(code) && code >= 3000 && code <= 4999))) {
|
|
551
|
+
throw new InferloError("IL2012", `Invalid WebSocket close code ${String(code)}.`, { fix: "Close with 1000 (normal), or an application code from 3000 to 4999: socket.close(4000, \"reason\")." });
|
|
552
|
+
}
|
|
553
|
+
end(code, reason);
|
|
554
|
+
},
|
|
555
|
+
join: (room) => socketHub.join(member, room, tenantOf(scope)),
|
|
556
|
+
leave: (room) => socketHub.leave(member, room, tenantOf(scope)),
|
|
557
|
+
stream: async (result) => {
|
|
558
|
+
if (!result || typeof result[Symbol.asyncIterator] !== "function" || typeof result.usage !== "function") {
|
|
559
|
+
throw new InferloError("IL2013", "socket.stream() takes the result of ai.stream(...).", { fix: 'Pass the stream itself: await socket.stream(ai.stream({ model: "fast", prompt })).' });
|
|
560
|
+
}
|
|
561
|
+
let text = "";
|
|
562
|
+
// After the socket closes, sends do nothing; with ctx.ai the stream itself is cancelled by ctx.signal.
|
|
563
|
+
for await (const chunk of result) {
|
|
564
|
+
text += chunk;
|
|
565
|
+
socket.send({ type: "text", text: chunk });
|
|
566
|
+
}
|
|
567
|
+
const usage = await result.usage();
|
|
568
|
+
socket.send({ type: "done", usage });
|
|
569
|
+
return { text, usage };
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
let socketDb;
|
|
573
|
+
let socketRooms;
|
|
574
|
+
let queues;
|
|
575
|
+
const tenantQueues = () => (queues ??= typeof scope === "object" ? withTenant({ jobs, events }, scope.tenant) : { jobs, events });
|
|
576
|
+
const handleMessage = async (data) => {
|
|
577
|
+
if (typeof data !== "string")
|
|
578
|
+
return end(1003, "Send JSON text messages");
|
|
579
|
+
if (data.length > maxBody / 3 && encoder.encode(data).byteLength > maxBody)
|
|
580
|
+
return end(1009, "Message too big");
|
|
581
|
+
try {
|
|
582
|
+
let raw;
|
|
583
|
+
try {
|
|
584
|
+
raw = JSON.parse(data);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
throw new InferloError("IL2003", "Message is not valid JSON.", { status: 400, fix: "Send each message as JSON text: socket.send(JSON.stringify(message))." });
|
|
588
|
+
}
|
|
589
|
+
if (opts.rateLimit) {
|
|
590
|
+
const limited = rateLimitResult(opts.rateLimit, await rateLimits.hit(rateLimitKey(def, user, ip), windowMs(opts.rateLimit)));
|
|
591
|
+
if (limited.error)
|
|
592
|
+
throw limited.error;
|
|
593
|
+
}
|
|
594
|
+
const message = opts.message ? parseOrThrow(opts.message, raw, "message", false) : raw;
|
|
595
|
+
const ctx = {
|
|
596
|
+
message,
|
|
597
|
+
query: conn.query,
|
|
598
|
+
params: conn.params,
|
|
599
|
+
user,
|
|
600
|
+
socket,
|
|
601
|
+
get db() {
|
|
602
|
+
return (socketDb ??= bindDb(models, adapter, scope));
|
|
603
|
+
},
|
|
604
|
+
get ai() {
|
|
605
|
+
const value = toolsFor(req, closed.signal);
|
|
606
|
+
Object.defineProperty(ctx, "ai", { value, enumerable: true });
|
|
607
|
+
return value;
|
|
608
|
+
},
|
|
609
|
+
get jobs() {
|
|
610
|
+
return tenantQueues().jobs;
|
|
611
|
+
},
|
|
612
|
+
get events() {
|
|
613
|
+
return tenantQueues().events;
|
|
614
|
+
},
|
|
615
|
+
cache,
|
|
616
|
+
storage,
|
|
617
|
+
get sockets() {
|
|
618
|
+
return (socketRooms ??= socketHub.forTenant(tenantOf(scope)));
|
|
619
|
+
},
|
|
620
|
+
get audit() {
|
|
621
|
+
const value = createAudit(models, redactor, () => ctx.db, { userId: user?.id ?? null, tenantId: tenantOf(scope), source: `WS ${def.path}`, requestId });
|
|
622
|
+
Object.defineProperty(ctx, "audit", { value, enumerable: true });
|
|
623
|
+
return value;
|
|
624
|
+
},
|
|
625
|
+
signal: closed.signal,
|
|
626
|
+
requestId,
|
|
627
|
+
url,
|
|
628
|
+
headers: req.headers,
|
|
629
|
+
};
|
|
630
|
+
const result = await def.handler(ctx);
|
|
631
|
+
if (result !== undefined)
|
|
632
|
+
socket.send(result);
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
if (open)
|
|
636
|
+
transport.send(JSON.stringify(socketError(error, def, requestId)));
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
let chain = Promise.resolve();
|
|
640
|
+
return {
|
|
641
|
+
message(data) {
|
|
642
|
+
if (!open)
|
|
643
|
+
return;
|
|
644
|
+
// One message at a time, in order; app.close() waits for the one running.
|
|
645
|
+
const next = chain.then(() => (open ? handleMessage(data) : undefined));
|
|
646
|
+
chain = next;
|
|
647
|
+
tasks.add(next);
|
|
648
|
+
void next.finally(() => {
|
|
649
|
+
tasks.delete(next);
|
|
650
|
+
settle();
|
|
651
|
+
});
|
|
652
|
+
},
|
|
653
|
+
close() {
|
|
654
|
+
open = false;
|
|
655
|
+
finish();
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
const dispatch = async (req, url, state, pending, ip, requestId, seen, rest) => {
|
|
371
660
|
const headers = new Headers();
|
|
372
661
|
if (cors)
|
|
373
662
|
for (const [k, v] of Object.entries(corsHeaders(cors, req.headers.get("origin"))))
|
|
@@ -457,8 +746,18 @@ export function createApp(config = {}) {
|
|
|
457
746
|
const limit = opts.timeoutMs === undefined ? requestTimeoutMs : opts.timeoutMs;
|
|
458
747
|
// timeout aborts when the route takes too long; signal (ctx.signal) also when the client disconnects.
|
|
459
748
|
const timeout = new AbortController();
|
|
460
|
-
|
|
749
|
+
// Combined on first use: AbortSignal.any is costly, and most handlers never read ctx.signal.
|
|
750
|
+
let combined;
|
|
751
|
+
const signal = () => (combined ??= AbortSignal.any([clientSignals.get(req) ?? req.signal, timeout.signal]));
|
|
461
752
|
let requestDb;
|
|
753
|
+
let queues;
|
|
754
|
+
let requestUser = null;
|
|
755
|
+
const tenantQueues = () => {
|
|
756
|
+
if (queues)
|
|
757
|
+
return queues;
|
|
758
|
+
const scope = tenantScope(requestUser);
|
|
759
|
+
return (queues = typeof scope === "object" ? withTenant({ jobs, events }, scope.tenant) : { jobs, events });
|
|
760
|
+
};
|
|
462
761
|
const run = async () => {
|
|
463
762
|
// Auth
|
|
464
763
|
let user = null;
|
|
@@ -466,6 +765,7 @@ export function createApp(config = {}) {
|
|
|
466
765
|
user = testUsers.get(req) ?? null;
|
|
467
766
|
else if (config.auth)
|
|
468
767
|
user = (await config.auth.resolve(req, { db: db, url })) ?? null;
|
|
768
|
+
requestUser = user;
|
|
469
769
|
const level = opts.auth ?? "user";
|
|
470
770
|
if (level !== "public") {
|
|
471
771
|
if (!user)
|
|
@@ -475,10 +775,10 @@ export function createApp(config = {}) {
|
|
|
475
775
|
}
|
|
476
776
|
if (opts.permission)
|
|
477
777
|
await config.permissions.authorize(opts.permission, { user });
|
|
478
|
-
// Rate limit: counted per user
|
|
778
|
+
// Rate limit: counted per user, tenant or IP, after auth so failed sign-ins on private routes do not use it up.
|
|
779
|
+
// by "tenant" counts users without a tenant one by one; by "user" and "tenant" count requests without a user by IP.
|
|
479
780
|
if (opts.rateLimit) {
|
|
480
|
-
const
|
|
481
|
-
const hit = await rateLimits.hit(`${def.method} ${def.path} ${who}`, windowMs(opts.rateLimit));
|
|
781
|
+
const hit = await rateLimits.hit(rateLimitKey(def, user, ip), windowMs(opts.rateLimit));
|
|
482
782
|
const limited = rateLimitResult(opts.rateLimit, hit);
|
|
483
783
|
for (const [k, v] of Object.entries(limited.headers))
|
|
484
784
|
headers.set(k, v);
|
|
@@ -495,10 +795,24 @@ export function createApp(config = {}) {
|
|
|
495
795
|
const params = opts.params ? parseOrThrow(opts.params, rawParams, "path parameters", true) : rawParams;
|
|
496
796
|
const rawQuery = queryObject(url);
|
|
497
797
|
const query = opts.query ? parseOrThrow(opts.query.strip(), rawQuery, "query string", true) : rawQuery;
|
|
798
|
+
// WebSocket routes answer the upgrade here; their handler then runs once per message.
|
|
799
|
+
if (def.websocket)
|
|
800
|
+
return await acceptSocket(def, req, url, headers, rest, { user, params, query, requestId, ip });
|
|
498
801
|
let body;
|
|
499
|
-
|
|
802
|
+
const contentType = req.headers.get("content-type") ?? "";
|
|
803
|
+
const filesLimit = fileBudgets.get(def);
|
|
804
|
+
if (opts.body && filesLimit !== undefined && isMultipart(contentType) && method !== "GET" && method !== "HEAD") {
|
|
805
|
+
// File uploads: read with the route's limit (maxBodySize plus its files' maxBytes), then check sizes,
|
|
806
|
+
// types and file contents before the handler runs.
|
|
807
|
+
const bytes = await readBytes(req, maxBody + filesLimit, timeout.signal);
|
|
808
|
+
body = parseOrThrow(opts.body, await readMultipart(bytes, contentType, opts.body), "request body", true);
|
|
809
|
+
const issues = await checkFileContents(opts.body, body);
|
|
810
|
+
if (issues.length)
|
|
811
|
+
throw new ValidationError(issues, "request body");
|
|
812
|
+
}
|
|
813
|
+
else if (opts.body) {
|
|
500
814
|
const text = method === "GET" || method === "HEAD" ? "" : await readBody(req, maxBody, timeout.signal);
|
|
501
|
-
const type =
|
|
815
|
+
const type = contentType;
|
|
502
816
|
let raw = undefined;
|
|
503
817
|
if (text) {
|
|
504
818
|
if (type.includes("application/x-www-form-urlencoded")) {
|
|
@@ -521,13 +835,13 @@ export function createApp(config = {}) {
|
|
|
521
835
|
// Response cache: after auth, permissions, rate limits and input checks, so a cached response is only
|
|
522
836
|
// served to callers who may call the route with valid input.
|
|
523
837
|
const routeCache = opts.cache;
|
|
524
|
-
const cacheKey = routeCache ? await routeCacheKey(def.method, def.path, routeCache, user?.id, url) : undefined;
|
|
838
|
+
const cacheKey = routeCache ? await routeCacheKey(def.method, def.path, routeCache, user?.id, url, routeCache.by === "tenant" && user ? tenantOf(tenantScope(user)) : undefined) : undefined;
|
|
525
839
|
const cacheHeaders = (outcome, etag) => {
|
|
526
840
|
headers.set("x-inferlo-cache", outcome);
|
|
527
841
|
headers.set("etag", etag);
|
|
528
842
|
if (!headers.has("cache-control"))
|
|
529
843
|
headers.set("cache-control", `${(routeCache.by ?? "user") === "public" && (opts.auth ?? "user") === "public" ? "public" : "private"}, no-cache`);
|
|
530
|
-
if ((routeCache.by ?? "user")
|
|
844
|
+
if ((routeCache.by ?? "user") !== "public")
|
|
531
845
|
headers.append("vary", "Authorization, Cookie");
|
|
532
846
|
};
|
|
533
847
|
const cachedAnswer = (body, etag) => {
|
|
@@ -554,13 +868,31 @@ export function createApp(config = {}) {
|
|
|
554
868
|
user,
|
|
555
869
|
get db() {
|
|
556
870
|
// Built on first use: after a timeout, new queries fail with IL2007 instead of running.
|
|
557
|
-
|
|
871
|
+
// Tenant-scoped tables are filtered by the signed-in user's tenant (none without one).
|
|
872
|
+
return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal), tenantScope(user)));
|
|
873
|
+
},
|
|
874
|
+
// ctx.ai and ctx.audit are built on first use too, so routes that do not use them pay nothing.
|
|
875
|
+
get ai() {
|
|
876
|
+
const value = toolsFor(req, signal());
|
|
877
|
+
Object.defineProperty(ctx, "ai", { value, enumerable: true });
|
|
878
|
+
return value;
|
|
879
|
+
},
|
|
880
|
+
get signal() {
|
|
881
|
+
return signal();
|
|
558
882
|
},
|
|
559
|
-
ai: toolsFor(req, signal),
|
|
560
|
-
signal,
|
|
561
883
|
requestId,
|
|
562
|
-
jobs
|
|
884
|
+
// With a tenant, jobs and events queued by the request carry it.
|
|
885
|
+
get jobs() {
|
|
886
|
+
return tenantQueues().jobs;
|
|
887
|
+
},
|
|
888
|
+
get events() {
|
|
889
|
+
return tenantQueues().events;
|
|
890
|
+
},
|
|
563
891
|
cache,
|
|
892
|
+
storage,
|
|
893
|
+
get sockets() {
|
|
894
|
+
return socketHub.forTenant(tenantOf(tenantScope(user)));
|
|
895
|
+
},
|
|
564
896
|
req,
|
|
565
897
|
url,
|
|
566
898
|
headers: req.headers,
|
|
@@ -575,7 +907,11 @@ export function createApp(config = {}) {
|
|
|
575
907
|
handlerHeaders.push([name.toLowerCase(), value]);
|
|
576
908
|
},
|
|
577
909
|
waitUntil: (p) => void pending.push(p.catch((e) => logger.error("waitUntil task failed", e, { requestId }))),
|
|
578
|
-
audit
|
|
910
|
+
get audit() {
|
|
911
|
+
const value = createAudit(models, redactor, () => ctx.db, { userId: user?.id ?? null, tenantId: tenantOf(tenantScope(user)), source: `${def.method} ${def.path}`, requestId });
|
|
912
|
+
Object.defineProperty(ctx, "audit", { value, enumerable: true });
|
|
913
|
+
return value;
|
|
914
|
+
},
|
|
579
915
|
};
|
|
580
916
|
const result = await def.handler(ctx);
|
|
581
917
|
if (result instanceof Response) {
|
|
@@ -589,6 +925,24 @@ export function createApp(config = {}) {
|
|
|
589
925
|
}
|
|
590
926
|
if (result === undefined)
|
|
591
927
|
return new Response(null, { status: status || 204, headers });
|
|
928
|
+
if (validateResponses && opts.response && (status || 200) < 300) {
|
|
929
|
+
const issues = responseIssues(opts.response, result);
|
|
930
|
+
if (issues) {
|
|
931
|
+
const where = `${def.method} ${def.path}`;
|
|
932
|
+
const summary = issues.slice(0, 5).map((i) => `${i.path.length ? i.path.join(".") : "(root)"}: ${i.message}`).join("; ") + (issues.length > 5 ? ` (and ${issues.length - 5} more)` : "");
|
|
933
|
+
if (validateResponses === "error") {
|
|
934
|
+
throw new InferloError("IL2016", `${where}: the response does not match the route's response schema. ${summary}`, {
|
|
935
|
+
expose: true,
|
|
936
|
+
fix: "Return what the route's response schema describes, or change the schema. Fields not in an object schema count as mismatches, so the check also finds fields that should not be sent.",
|
|
937
|
+
details: issues.map((i) => ({ path: i.path, code: i.code, message: i.message })),
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
if (!warnedResponses.has(def)) {
|
|
941
|
+
warnedResponses.add(def);
|
|
942
|
+
logger.error(`IL2016 ${where}: the response does not match the route's response schema (sent anyway; logged once per route). ${summary}`, undefined, { requestId });
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
592
946
|
// Only plain 200 JSON answers are cached, never one that sets a cookie.
|
|
593
947
|
if (cacheKey && (status || 200) === 200 && !handlerHeaders.some(([name]) => name === "set-cookie")) {
|
|
594
948
|
const body = JSON.stringify(result);
|
|
@@ -697,7 +1051,7 @@ export function createApp(config = {}) {
|
|
|
697
1051
|
const run = (i) => {
|
|
698
1052
|
const mw = chain[i];
|
|
699
1053
|
if (!mw)
|
|
700
|
-
return dispatch(req, url, state, pending, ip, requestId, seen);
|
|
1054
|
+
return dispatch(req, url, state, pending, ip, requestId, seen, rest);
|
|
701
1055
|
return mw({ req, url, state, requestId }, () => run(i + 1));
|
|
702
1056
|
};
|
|
703
1057
|
let response;
|
|
@@ -715,10 +1069,12 @@ export function createApp(config = {}) {
|
|
|
715
1069
|
if (!probe) {
|
|
716
1070
|
const elapsed = performance.now() - started;
|
|
717
1071
|
const method = req.method.toUpperCase();
|
|
718
|
-
|
|
1072
|
+
// An accepted WebSocket upgrade is logged and counted as the 101 the client gets.
|
|
1073
|
+
const status = upgrades.has(req) ? 101 : response.status;
|
|
1074
|
+
metrics?.request(method, seen.route, status, elapsed / 1000);
|
|
719
1075
|
if (logging.requests) {
|
|
720
1076
|
const durationMs = Math.round(elapsed * 10) / 10;
|
|
721
|
-
logger.info(`${method} ${url.pathname} ${
|
|
1077
|
+
logger.info(`${method} ${url.pathname} ${status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status, durationMs });
|
|
722
1078
|
}
|
|
723
1079
|
}
|
|
724
1080
|
if (secureHeaders.length) {
|
|
@@ -727,9 +1083,9 @@ export function createApp(config = {}) {
|
|
|
727
1083
|
}
|
|
728
1084
|
if (pending.length) {
|
|
729
1085
|
const all = Promise.all(pending);
|
|
730
|
-
const execution = rest
|
|
731
|
-
if (execution
|
|
732
|
-
execution
|
|
1086
|
+
const execution = platformWaitUntil(rest);
|
|
1087
|
+
if (execution)
|
|
1088
|
+
execution(all);
|
|
733
1089
|
else {
|
|
734
1090
|
// Kept until it settles, so app.close() waits for it.
|
|
735
1091
|
tasks.add(all);
|
|
@@ -750,7 +1106,10 @@ export function createApp(config = {}) {
|
|
|
750
1106
|
db,
|
|
751
1107
|
ai,
|
|
752
1108
|
jobs,
|
|
1109
|
+
events,
|
|
753
1110
|
cache,
|
|
1111
|
+
storage,
|
|
1112
|
+
sockets: { broadcast: socketHub.broadcast, count: socketHub.count },
|
|
754
1113
|
get closing() {
|
|
755
1114
|
return closing;
|
|
756
1115
|
},
|
|
@@ -758,7 +1117,16 @@ export function createApp(config = {}) {
|
|
|
758
1117
|
inFlight++;
|
|
759
1118
|
metrics?.inFlight(1);
|
|
760
1119
|
try {
|
|
761
|
-
|
|
1120
|
+
const response = await handle(req, rest);
|
|
1121
|
+
// Deno needs its own upgrade response back, untouched; Bun.serve wants undefined for a request it upgraded.
|
|
1122
|
+
const upgrade = upgrades.get(req);
|
|
1123
|
+
if (upgrade)
|
|
1124
|
+
return upgrade;
|
|
1125
|
+
if (upgrade === null)
|
|
1126
|
+
return undefined;
|
|
1127
|
+
if (rest.length)
|
|
1128
|
+
keepEventStreamOpen(rest[0], req, response);
|
|
1129
|
+
return response;
|
|
762
1130
|
}
|
|
763
1131
|
finally {
|
|
764
1132
|
inFlight--;
|
|
@@ -777,6 +1145,13 @@ export function createApp(config = {}) {
|
|
|
777
1145
|
body = JSON.stringify(options.json);
|
|
778
1146
|
headers["content-type"] ??= "application/json";
|
|
779
1147
|
}
|
|
1148
|
+
if (typeof FormData !== "undefined" && body instanceof FormData) {
|
|
1149
|
+
// Sent as bytes: on Node.js 24, cancelling a request body stream made from FormData (as a too large upload
|
|
1150
|
+
// does) raises an unhandled rejection inside fetch's FormData encoder.
|
|
1151
|
+
const encoded = new Response(body);
|
|
1152
|
+
headers["content-type"] ??= encoded.headers.get("content-type");
|
|
1153
|
+
body = new Uint8Array(await encoded.arrayBuffer());
|
|
1154
|
+
}
|
|
780
1155
|
const req = new Request(url, { method: (options.method ?? "GET").toUpperCase(), headers, body });
|
|
781
1156
|
if ("as" in options)
|
|
782
1157
|
testUsers.set(req, options.as ?? null);
|
|
@@ -820,6 +1195,9 @@ export function createApp(config = {}) {
|
|
|
820
1195
|
if (delayMs > 0)
|
|
821
1196
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
822
1197
|
refusing = true;
|
|
1198
|
+
// Open WebSockets are told the server is going away (1001); a message being handled still finishes.
|
|
1199
|
+
for (const shutdown of [...sockets])
|
|
1200
|
+
shutdown();
|
|
823
1201
|
const deadline = Date.now() + timeoutMs;
|
|
824
1202
|
let workersStopped = false;
|
|
825
1203
|
const drained = Promise.all([whenIdle(), stopWorkers(jobs).then(() => void (workersStopped = true))]);
|
|
@@ -835,6 +1213,7 @@ export function createApp(config = {}) {
|
|
|
835
1213
|
if (!(await within(server.close(), Math.max(0, deadline - Date.now()))))
|
|
836
1214
|
forceClosers?.get(server)?.();
|
|
837
1215
|
}));
|
|
1216
|
+
await socketHub.stop().catch((error) => logger.error("WebSocket broadcasts: unsubscribing failed", error));
|
|
838
1217
|
await db.$close();
|
|
839
1218
|
})();
|
|
840
1219
|
return closed;
|
|
@@ -881,7 +1260,7 @@ export function createApp(config = {}) {
|
|
|
881
1260
|
});
|
|
882
1261
|
return app;
|
|
883
1262
|
}
|
|
884
|
-
function resolveHealth(option, routes, taken) {
|
|
1263
|
+
function resolveHealth(option, routes, taken, builtIn) {
|
|
885
1264
|
if (option === undefined || option === false)
|
|
886
1265
|
return undefined;
|
|
887
1266
|
const bad = (message, fix) => new InferloError("IL6014", message, { fix });
|
|
@@ -898,9 +1277,9 @@ function resolveHealth(option, routes, taken) {
|
|
|
898
1277
|
if (!Number.isFinite(resolved.timeoutMs) || resolved.timeoutMs <= 0)
|
|
899
1278
|
throw bad(`health.timeoutMs must be a positive number of milliseconds, got ${String(resolved.timeoutMs)}.`, "Use health: { timeoutMs: 5000 }.");
|
|
900
1279
|
if (typeof resolved.checks !== "object" || resolved.checks === null)
|
|
901
|
-
throw bad("health.checks must be an object of functions.", "Use health: { checks: {
|
|
1280
|
+
throw bad("health.checks must be an object of functions.", "Use health: { checks: { search: () => search.ping() } }.");
|
|
902
1281
|
for (const [name, check] of Object.entries(resolved.checks)) {
|
|
903
|
-
if (name === "database" || name === "ai")
|
|
1282
|
+
if (name === "database" || name === "ai" || builtIn.includes(name))
|
|
904
1283
|
throw bad(`health.checks.${name} is built in.`, `Give your check another name, for example "${name}Replica".`);
|
|
905
1284
|
if (typeof check !== "function")
|
|
906
1285
|
throw bad(`health.checks.${name} must be a function.`, `Use health: { checks: { ${name}: async () => { ... } } }; throw when it is not ready.`);
|
|
@@ -927,4 +1306,25 @@ function parseOrThrow(schema, value, where, coerce) {
|
|
|
927
1306
|
throw new ValidationError(result.issues, where);
|
|
928
1307
|
return result.value;
|
|
929
1308
|
}
|
|
1309
|
+
function resolveValidateResponses(value) {
|
|
1310
|
+
if (value === undefined)
|
|
1311
|
+
return (readEnv("INFERLO_ENV") || readEnv("NODE_ENV") || "development") === "production" ? false : "warn";
|
|
1312
|
+
if (value === "warn" || value === "error" || value === false)
|
|
1313
|
+
return value;
|
|
1314
|
+
throw new InferloError("IL6040", `Invalid validateResponses option ${JSON.stringify(value)}.`, {
|
|
1315
|
+
fix: 'Use createApp({ validateResponses: "warn" }) (the default outside production), "error" in tests, or false to turn the check off.',
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
/** The issues of the answer as clients receive it (JSON), or undefined when it matches. */
|
|
1319
|
+
function responseIssues(schema, result) {
|
|
1320
|
+
let sent;
|
|
1321
|
+
try {
|
|
1322
|
+
sent = JSON.parse(JSON.stringify(result));
|
|
1323
|
+
}
|
|
1324
|
+
catch {
|
|
1325
|
+
return undefined; // Not JSON: the answer itself fails with its own error.
|
|
1326
|
+
}
|
|
1327
|
+
const parsed = schema.safeParse(sent);
|
|
1328
|
+
return parsed.ok ? undefined : parsed.issues;
|
|
1329
|
+
}
|
|
930
1330
|
//# sourceMappingURL=app.js.map
|