@browserlessapi/inferlo 0.7.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 +3 -2
- package/dist/app.d.ts +24 -0
- package/dist/app.js +336 -13
- package/dist/app.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/better-auth.d.ts +89 -0
- package/dist/better-auth.js +75 -0
- package/dist/better-auth.js.map +1 -0
- package/dist/cli/main.js +1 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/client-ip.d.ts +5 -0
- package/dist/client-ip.js +5 -0
- 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/postgres.js +19 -5
- package/dist/db/postgres.js.map +1 -1
- package/dist/describe.d.ts +9 -2
- package/dist/describe.js +11 -1
- 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/files.d.ts +23 -0
- package/dist/files.js +168 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +15 -4
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs.d.ts +10 -4
- package/dist/jobs.js +7 -2
- 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/model.js +6 -1
- 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/redis-sockets.d.ts +24 -0
- package/dist/redis-sockets.js +75 -0
- package/dist/redis-sockets.js.map +1 -0
- package/dist/route.d.ts +103 -2
- package/dist/route.js +27 -0
- package/dist/route.js.map +1 -1
- package/dist/schema.d.ts +21 -1
- package/dist/schema.js +70 -0
- package/dist/schema.js.map +1 -1
- package/dist/serve.d.ts +18 -0
- package/dist/serve.js +137 -2
- package/dist/serve.js.map +1 -1
- package/dist/sessions.js +35 -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/websocket.d.ts +81 -0
- package/dist/websocket.js +251 -0
- package/dist/websocket.js.map +1 -0
- package/llms.txt +1 -0
- package/package.json +43 -3
- package/templates/app/AGENTS.md +5 -3
- package/templates/app/test/notes.test.ts +2 -0
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ That one model definition gives you the database table, insert and update valida
|
|
|
47
47
|
- **Embeddings** for search and recommendations.
|
|
48
48
|
- **Usage, cost and budgets.** Every call is tracked per user, so you can bill customers and stop calls when their credits run out.
|
|
49
49
|
- **MCP server.** Mark a route `tool: true` and AI agents can call your API, with the same permissions as any other client.
|
|
50
|
-
- **Use it in any framework.** `import { createAI } from "@browserlessapi/inferlo/ai"` gives you the same AI module without `createApp`, for Express, Hono, Fastify, NestJS or a plain script, and `toNodeHandler(app, { prefix: "/ai" })` mounts a whole Inferlo app (routes, OpenAPI, MCP) inside Express, Fastify, NestJS or `node:http`, `hono.use(inferloAI(ai))` from `@browserlessapi/inferlo/hono` gives Hono handlers `c.var.ai`, `server.use(inferloAI(ai))` from `@browserlessapi/inferlo/express` gives Express handlers `req.ai`, and `fastify.register(inferloPlugin, { ai })` from `@browserlessapi/inferlo/fastify` gives Fastify `request.ai`, `server.register({ plugin: inferloPlugin, options: { ai } })` from `@browserlessapi/inferlo/hapi` gives hapi `request.ai` (and `options: { app }` with `routes: { prefix }` serves a whole Inferlo app), `new Elysia().use(inferloAI(ai))` from `@browserlessapi/inferlo/elysia` gives Elysia handlers `ai`, `InferloModule.forRoot({ ai })` from `@browserlessapi/inferlo/nestjs` makes the AI client injectable in NestJS, the provider from `@browserlessapi/inferlo/adonisjs` gives AdonisJS routes `ctx.ai`, `@Configuration({ imports: [inferloAI(ai)] })` from `@browserlessapi/inferlo/tsed` makes the AI client injectable in Ts.ED as `InferloAI`, `trpc.procedure.use(inferloAI(ai))` from `@browserlessapi/inferlo/trpc` gives tRPC procedures `ctx.ai` while `trpcTools(router, { caller, procedures })` lets a model call tRPC procedures as tools, `koa.use(inferloAI(ai))` and `koa.use(toKoaMiddleware(app, { prefix: "/api" }))` from `@browserlessapi/inferlo/koa` give Koa `ctx.ai` and a whole Inferlo app, `export const { GET, POST } = toNextHandlers(app, { prefix: "/api" })` from `@browserlessapi/inferlo/nextjs` serves a whole Inferlo app from a Next.js App Router route, `export default toH3Handler(app, { prefix: "/api" })` from `@browserlessapi/inferlo/h3` serves it from a Nuxt or Nitro server route (with `event.context.ai` from a Nitro plugin), `export const { GET, POST, fallback } = toSvelteKitHandlers(app, { prefix: "/api" })` from `@browserlessapi/inferlo/sveltekit` serves it from a SvelteKit rest route (with `event.locals.ai` from a handle hook), `export const ALL = toAstroHandler(app, { prefix: "/api" })` from `@browserlessapi/inferlo/astro` serves it from an Astro endpoint (with `locals.ai` from a middleware), `export const { loader, action } = toReactRouterHandlers(app, { prefix: "/api" })` from `@browserlessapi/inferlo/react-router` serves it from a React Router (Remix) resource route, `export const handler = toLambdaHandler(app)` from `@browserlessapi/inferlo/lambda` runs it on AWS Lambda (function URLs and API Gateway, with response streaming), `handler: toAzureHandler(app, { prefix: "/api" })` from `@browserlessapi/inferlo/azure` runs it in an Azure Functions HTTP trigger (with streaming), `functions.http("inferlo", toNodeHandler(app))` runs it as a Google Cloud Run function, `export default app` runs it as a Cloudflare Worker or a Vercel Function and `export default app.fetch` as a Netlify Function, and an Encore.ts raw endpoint serves a whole Inferlo app with `api.raw({ expose: true, method: "*", path: "/inferlo/*rest" }, toNodeHandler(app, { prefix: "/inferlo" }))` An app that already uses Drizzle ORM adds Inferlo on the same Postgres pool, with Drizzle and Inferlo writes in one transaction through `tx.$pgClient()` (see [Use with other frameworks](docs/integrations.md)).
|
|
50
|
+
- **Use it in any framework.** `import { createAI } from "@browserlessapi/inferlo/ai"` gives you the same AI module without `createApp`, for Express, Hono, Fastify, NestJS or a plain script, and `toNodeHandler(app, { prefix: "/ai" })` mounts a whole Inferlo app (routes, OpenAPI, MCP) inside Express, Fastify, NestJS or `node:http`, `hono.use(inferloAI(ai))` from `@browserlessapi/inferlo/hono` gives Hono handlers `c.var.ai`, `server.use(inferloAI(ai))` from `@browserlessapi/inferlo/express` gives Express handlers `req.ai`, and `fastify.register(inferloPlugin, { ai })` from `@browserlessapi/inferlo/fastify` gives Fastify `request.ai`, `server.register({ plugin: inferloPlugin, options: { ai } })` from `@browserlessapi/inferlo/hapi` gives hapi `request.ai` (and `options: { app }` with `routes: { prefix }` serves a whole Inferlo app), `new Elysia().use(inferloAI(ai))` from `@browserlessapi/inferlo/elysia` gives Elysia handlers `ai`, `InferloModule.forRoot({ ai })` from `@browserlessapi/inferlo/nestjs` makes the AI client injectable in NestJS, the provider from `@browserlessapi/inferlo/adonisjs` gives AdonisJS routes `ctx.ai`, `@Configuration({ imports: [inferloAI(ai)] })` from `@browserlessapi/inferlo/tsed` makes the AI client injectable in Ts.ED as `InferloAI`, `trpc.procedure.use(inferloAI(ai))` from `@browserlessapi/inferlo/trpc` gives tRPC procedures `ctx.ai` while `trpcTools(router, { caller, procedures })` lets a model call tRPC procedures as tools, `koa.use(inferloAI(ai))` and `koa.use(toKoaMiddleware(app, { prefix: "/api" }))` from `@browserlessapi/inferlo/koa` give Koa `ctx.ai` and a whole Inferlo app, `export const { GET, POST } = toNextHandlers(app, { prefix: "/api" })` from `@browserlessapi/inferlo/nextjs` serves a whole Inferlo app from a Next.js App Router route, `export default toH3Handler(app, { prefix: "/api" })` from `@browserlessapi/inferlo/h3` serves it from a Nuxt or Nitro server route (with `event.context.ai` from a Nitro plugin), `export const { GET, POST, fallback } = toSvelteKitHandlers(app, { prefix: "/api" })` from `@browserlessapi/inferlo/sveltekit` serves it from a SvelteKit rest route (with `event.locals.ai` from a handle hook), `export const ALL = toAstroHandler(app, { prefix: "/api" })` from `@browserlessapi/inferlo/astro` serves it from an Astro endpoint (with `locals.ai` from a middleware), `export const { loader, action } = toReactRouterHandlers(app, { prefix: "/api" })` from `@browserlessapi/inferlo/react-router` serves it from a React Router (Remix) resource route, `export const handler = toLambdaHandler(app)` from `@browserlessapi/inferlo/lambda` runs it on AWS Lambda (function URLs and API Gateway, with response streaming), `handler: toAzureHandler(app, { prefix: "/api" })` from `@browserlessapi/inferlo/azure` runs it in an Azure Functions HTTP trigger (with streaming), `functions.http("inferlo", toNodeHandler(app))` runs it as a Google Cloud Run function, `export default app` runs it as a Cloudflare Worker or a Vercel Function and `export default app.fetch` as a Netlify Function, and an Encore.ts raw endpoint serves a whole Inferlo app with `api.raw({ expose: true, method: "*", path: "/inferlo/*rest" }, toNodeHandler(app, { prefix: "/inferlo" }))` An app that already uses Drizzle ORM or Prisma adds Inferlo on the same Postgres pool, with Drizzle and Inferlo writes in one transaction through `tx.$pgClient()` (see [Use with other frameworks](docs/integrations.md)).
|
|
51
51
|
- **Background jobs for slow AI work.** Queue a typed job from a route with `ctx.jobs.enqueue(...)` and it runs after the response, with retries, from a Postgres queue shared by every instance (`inferlo worker` runs them in their own process), and schedule jobs with `cron("0 3 * * *", job)`.
|
|
52
52
|
|
|
53
53
|
## Why Inferlo
|
|
@@ -98,7 +98,7 @@ npm install @browserlessapi/inferlo
|
|
|
98
98
|
|
|
99
99
|
## Features
|
|
100
100
|
|
|
101
|
-
- **Routing:** `route.get/post/put/patch/delete`, typed path params, query coercion, CORS, middleware, body limits
|
|
101
|
+
- **Routing:** `route.get/post/put/patch/delete`, typed path params, query coercion, CORS, middleware, body limits, WebSockets with `route.ws(path, { message })` on Node.js, Bun and Deno
|
|
102
102
|
- **Schemas:** `t.string()`, `t.email()`, `t.enum()`, `t.ref()`, ... with `.optional()`, `.default()`, `.pick()`, `.partial()`, JSON Schema output
|
|
103
103
|
- **Database:** typed tables (`db.tickets.findMany(...)`), PostgreSQL and in-memory adapters, `inferlo db push`, versioned migrations (`inferlo db migrate`), transactions, unique and foreign keys
|
|
104
104
|
- **Auth:** `auth.resolve(req)`, `bearer()` API keys, roles, password hashing with Web Crypto
|
|
@@ -121,6 +121,7 @@ npm install @browserlessapi/inferlo
|
|
|
121
121
|
- [Modules](docs/modules.md)
|
|
122
122
|
- [Service-to-service calls](docs/services.md)
|
|
123
123
|
- [Caching](docs/caching.md)
|
|
124
|
+
- [File storage](docs/storage.md)
|
|
124
125
|
- [Redis](docs/redis.md)
|
|
125
126
|
- [MCP, OpenAPI and describe](docs/mcp.md)
|
|
126
127
|
- [CLI](docs/cli.md)
|
package/dist/app.d.ts
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
* fetch(Request) => Response handler that runs on Node, Bun, Deno and edge runtimes.
|
|
4
4
|
*/
|
|
5
5
|
import { type AI, type AIConfig } from "./ai/index.ts";
|
|
6
|
+
import { type Storage, type StorageStore } from "./storage.ts";
|
|
6
7
|
import { type Cache, type CacheConfig } from "./cache.ts";
|
|
7
8
|
import { type Db } from "./db/table.ts";
|
|
8
9
|
import type { Adapter } from "./db/types.ts";
|
|
9
10
|
import { type AppDescription } from "./describe.ts";
|
|
10
11
|
import type { AnyModel } from "./model.ts";
|
|
11
12
|
import { type AppDb, type AppUser, type Method, type RouteDef } from "./route.ts";
|
|
13
|
+
import { type AppSockets } from "./sockets.ts";
|
|
14
|
+
import { type SocketsConfig } from "./redis-sockets.ts";
|
|
12
15
|
import { type RedisClient } from "./redis.ts";
|
|
13
16
|
import { type RateLimitConfig } from "./rate-limit.ts";
|
|
14
17
|
import { type AppJobs, type JobQueueConfig, type JobSource } from "./jobs.ts";
|
|
@@ -118,6 +121,11 @@ export interface AppConfig<Models extends readonly AnyModel[], Modules extends r
|
|
|
118
121
|
* table inferlo_cache) or { store: "redis" } (createApp({ redis })), both shared by every instance.
|
|
119
122
|
*/
|
|
120
123
|
cache?: CacheConfig;
|
|
124
|
+
/**
|
|
125
|
+
* Where ctx.storage and app.storage keep files: diskStorage({ dir: "./uploads" }), memoryStorage() in tests, or
|
|
126
|
+
* your own StorageStore. Without it, ctx.storage throws IL6037.
|
|
127
|
+
*/
|
|
128
|
+
storage?: StorageStore;
|
|
121
129
|
/** Database adapter. Default: memory(). Use postgres() from "@browserlessapi/inferlo/postgres" in production. */
|
|
122
130
|
db?: Adapter;
|
|
123
131
|
auth?: AuthConfig;
|
|
@@ -138,6 +146,12 @@ export interface AppConfig<Models extends readonly AnyModel[], Modules extends r
|
|
|
138
146
|
* after app.close().
|
|
139
147
|
*/
|
|
140
148
|
redis?: RedisClient;
|
|
149
|
+
/**
|
|
150
|
+
* WebSocket broadcasts across instances: { transport: "redis", subscriber: await redis.duplicate().connect() }
|
|
151
|
+
* publishes every sockets.broadcast through Redis Pub/Sub (needs redis and name), so it reaches the sockets of
|
|
152
|
+
* every instance. Default: broadcasts reach the sockets of this process only.
|
|
153
|
+
*/
|
|
154
|
+
sockets?: SocketsConfig;
|
|
141
155
|
/**
|
|
142
156
|
* Where route rateLimit counts are kept: { store: "memory" } (default), { store: "database" } (Postgres) or
|
|
143
157
|
* { store: "redis" } (createApp({ redis })), both shared by every instance.
|
|
@@ -165,6 +179,12 @@ export interface AppConfig<Models extends readonly AnyModel[], Modules extends r
|
|
|
165
179
|
* 503 IL2007 and ctx.signal is aborted. Routes can set their own timeoutMs. false turns it off. Default 60000.
|
|
166
180
|
*/
|
|
167
181
|
requestTimeoutMs?: number | false;
|
|
182
|
+
/**
|
|
183
|
+
* Check each JSON answer of a route with a response schema against it: "warn" logs IL2016 once per route,
|
|
184
|
+
* "error" answers 500 IL2016 (for tests), false skips the check. Default: "warn", or false when the environment
|
|
185
|
+
* (INFERLO_ENV, then NODE_ENV) is "production".
|
|
186
|
+
*/
|
|
187
|
+
validateResponses?: "warn" | "error" | false;
|
|
168
188
|
/**
|
|
169
189
|
* More values to hide from logs, besides credentials Inferlo finds itself (authorization and cookie headers,
|
|
170
190
|
* tokens, API keys, passwords in URLs) and the AI provider keys: your config() result (its secrets) or
|
|
@@ -219,6 +239,10 @@ export interface App<Models extends readonly AnyModel[]> {
|
|
|
219
239
|
readonly events: AppEvents;
|
|
220
240
|
/** The app cache, the same as ctx.cache: await app.cache.getOrSet(key, { ttlMs, tags }, compute), app.cache.invalidate(tag). */
|
|
221
241
|
readonly cache: Cache;
|
|
242
|
+
/** Files, the same as ctx.storage: await app.storage.put(key, file), app.storage.get(key), app.storage.delete(key). */
|
|
243
|
+
readonly storage: Storage;
|
|
244
|
+
/** WebSocket rooms: await app.sockets.broadcast(room, data, { tenantId }) sends to the sockets that joined it in this process. */
|
|
245
|
+
readonly sockets: AppSockets;
|
|
222
246
|
/** Standard fetch handler: export default app works on Bun, Deno and Cloudflare Workers. */
|
|
223
247
|
fetch(req: Request, ...rest: unknown[]): Promise<Response>;
|
|
224
248
|
/** In-process request for tests: await app.request("/tickets", { method: "POST", json: {...}, as: user }). */
|
package/dist/app.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
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
11
|
import { bindDb, checkTenantId, createDb, guardAdapter } from "./db/table.js";
|
|
10
12
|
import { describeApp } from "./describe.js";
|
|
@@ -12,15 +14,19 @@ 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";
|
|
15
20
|
import { checkRedisClient, pingRedis, redisCacheStore, redisMissing, redisPrefix, redisRateLimitStore } from "./redis.js";
|
|
16
21
|
import { checkRateLimit, databaseRateLimitStore, memoryRateLimitStore, rateLimitResult, windowMs } from "./rate-limit.js";
|
|
17
22
|
import { clientIp, clientIps, clientSignals, keepEventStreamOpen, platformWaitUntil } from "./client-ip.js";
|
|
18
23
|
import { redisJobStore } from "./redis-jobs.js";
|
|
19
24
|
import { knownBodies } from "./response-body.js";
|
|
25
|
+
import { checkFileContents, checkFileFields, fileBudget, isMultipart, readMultipart } from "./files.js";
|
|
20
26
|
import { collectCrons, collectJobs, collectSubscriptions, createJobs, jobStoreFor, stopWorkers, withTenant } from "./jobs.js";
|
|
21
27
|
import { checkEventsConfig, invalidTransport } from "./events.js";
|
|
22
28
|
import { redisEventTransport } from "./redis-events.js";
|
|
23
|
-
import { ValidationError } from "./schema.js";
|
|
29
|
+
import { Schema, ValidationError } from "./schema.js";
|
|
24
30
|
import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
|
|
25
31
|
import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
|
|
26
32
|
import { clientTracing, jobTracing, requestTracing, traceAdapter, traceFields } from "./tracing.js";
|
|
@@ -67,11 +73,14 @@ function corsHeaders(config, origin) {
|
|
|
67
73
|
return h;
|
|
68
74
|
}
|
|
69
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) {
|
|
70
79
|
const length = Number(req.headers.get("content-length") ?? "0");
|
|
71
80
|
if (length > limit)
|
|
72
81
|
throw new InferloError("IL2004", `Request body is larger than ${limit} bytes.`, { status: 413 });
|
|
73
82
|
if (!req.body)
|
|
74
|
-
return
|
|
83
|
+
return new Uint8Array(0);
|
|
75
84
|
const reader = req.body.getReader();
|
|
76
85
|
// Stop reading a slow body once the request timed out.
|
|
77
86
|
signal?.addEventListener("abort", () => void reader.cancel().catch(() => { }), { once: true });
|
|
@@ -94,7 +103,7 @@ async function readBody(req, limit, signal) {
|
|
|
94
103
|
all.set(c, offset);
|
|
95
104
|
offset += c.byteLength;
|
|
96
105
|
}
|
|
97
|
-
return
|
|
106
|
+
return all;
|
|
98
107
|
}
|
|
99
108
|
function queryObject(url) {
|
|
100
109
|
const out = {};
|
|
@@ -179,6 +188,14 @@ export function createApp(config = {}) {
|
|
|
179
188
|
}
|
|
180
189
|
names.set(r.name, r);
|
|
181
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
|
+
}
|
|
182
199
|
const needsAuth = routes.filter((r) => (r.options.auth ?? "user") !== "public");
|
|
183
200
|
if (needsAuth.length && !config.auth) {
|
|
184
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(", ")}.`, {
|
|
@@ -205,10 +222,20 @@ export function createApp(config = {}) {
|
|
|
205
222
|
for (const r of routes)
|
|
206
223
|
if (r.options.rateLimit !== undefined)
|
|
207
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
|
+
}
|
|
208
233
|
for (const r of routes)
|
|
209
234
|
if (r.options.cache !== undefined)
|
|
210
235
|
checkRouteCache(r.options.cache, r.method, `${r.method} ${r.path}`);
|
|
211
236
|
const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
|
|
237
|
+
const validateResponses = resolveValidateResponses(config.validateResponses);
|
|
238
|
+
const warnedResponses = new Set();
|
|
212
239
|
for (const r of routes)
|
|
213
240
|
if (r.options.timeoutMs !== undefined)
|
|
214
241
|
checkTimeout(r.options.timeoutMs, `${r.method} ${r.path} { timeoutMs }`);
|
|
@@ -255,6 +282,7 @@ export function createApp(config = {}) {
|
|
|
255
282
|
throw redisMissing("cache");
|
|
256
283
|
const cacheStore = cacheConfig.store === "redis" ? redisCacheStore(redis, redisPrefix(config.name)) : cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries);
|
|
257
284
|
const cache = createCache(cacheStore, cacheConfig.store);
|
|
285
|
+
const storage = createStorage(checkStorageStore(config.storage));
|
|
258
286
|
const metrics = metricsConfig
|
|
259
287
|
? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
|
|
260
288
|
: undefined;
|
|
@@ -286,10 +314,14 @@ export function createApp(config = {}) {
|
|
|
286
314
|
}
|
|
287
315
|
else
|
|
288
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);
|
|
289
319
|
const jobs = createJobs(collectJobs(jobSources), {
|
|
290
320
|
db: db,
|
|
291
321
|
ai,
|
|
292
322
|
cache,
|
|
323
|
+
storage,
|
|
324
|
+
sockets: socketHub,
|
|
293
325
|
audit: (run) => createAudit(models, redactor, () => db, { userId: null, tenantId: run.tenantId, source: `job ${run.name}` }),
|
|
294
326
|
store: jobStoreFor(config.jobQueue, adapter, () => {
|
|
295
327
|
if (!redis)
|
|
@@ -364,6 +396,11 @@ export function createApp(config = {}) {
|
|
|
364
396
|
fix: "Pass routes that are registered in createApp({ routes }) to ai.generate({ tools }).",
|
|
365
397
|
});
|
|
366
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
|
+
}
|
|
367
404
|
return toolDefinition(found);
|
|
368
405
|
};
|
|
369
406
|
const describeTool = (route) => {
|
|
@@ -408,7 +445,218 @@ export function createApp(config = {}) {
|
|
|
408
445
|
const message = config.exposeErrors && error instanceof Error ? redactor.text(error.message) : "Internal server error";
|
|
409
446
|
return json({ error: { code: "IL9000", message, docs: new InferloError("IL9000", "").docs } }, 500, headers);
|
|
410
447
|
};
|
|
411
|
-
|
|
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) => {
|
|
412
660
|
const headers = new Headers();
|
|
413
661
|
if (cors)
|
|
414
662
|
for (const [k, v] of Object.entries(corsHeaders(cors, req.headers.get("origin"))))
|
|
@@ -530,10 +778,7 @@ export function createApp(config = {}) {
|
|
|
530
778
|
// Rate limit: counted per user, tenant or IP, after auth so failed sign-ins on private routes do not use it up.
|
|
531
779
|
// by "tenant" counts users without a tenant one by one; by "user" and "tenant" count requests without a user by IP.
|
|
532
780
|
if (opts.rateLimit) {
|
|
533
|
-
const
|
|
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}`;
|
|
536
|
-
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));
|
|
537
782
|
const limited = rateLimitResult(opts.rateLimit, hit);
|
|
538
783
|
for (const [k, v] of Object.entries(limited.headers))
|
|
539
784
|
headers.set(k, v);
|
|
@@ -550,10 +795,24 @@ export function createApp(config = {}) {
|
|
|
550
795
|
const params = opts.params ? parseOrThrow(opts.params, rawParams, "path parameters", true) : rawParams;
|
|
551
796
|
const rawQuery = queryObject(url);
|
|
552
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 });
|
|
553
801
|
let body;
|
|
554
|
-
|
|
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) {
|
|
555
814
|
const text = method === "GET" || method === "HEAD" ? "" : await readBody(req, maxBody, timeout.signal);
|
|
556
|
-
const type =
|
|
815
|
+
const type = contentType;
|
|
557
816
|
let raw = undefined;
|
|
558
817
|
if (text) {
|
|
559
818
|
if (type.includes("application/x-www-form-urlencoded")) {
|
|
@@ -630,6 +889,10 @@ export function createApp(config = {}) {
|
|
|
630
889
|
return tenantQueues().events;
|
|
631
890
|
},
|
|
632
891
|
cache,
|
|
892
|
+
storage,
|
|
893
|
+
get sockets() {
|
|
894
|
+
return socketHub.forTenant(tenantOf(tenantScope(user)));
|
|
895
|
+
},
|
|
633
896
|
req,
|
|
634
897
|
url,
|
|
635
898
|
headers: req.headers,
|
|
@@ -662,6 +925,24 @@ export function createApp(config = {}) {
|
|
|
662
925
|
}
|
|
663
926
|
if (result === undefined)
|
|
664
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
|
+
}
|
|
665
946
|
// Only plain 200 JSON answers are cached, never one that sets a cookie.
|
|
666
947
|
if (cacheKey && (status || 200) === 200 && !handlerHeaders.some(([name]) => name === "set-cookie")) {
|
|
667
948
|
const body = JSON.stringify(result);
|
|
@@ -770,7 +1051,7 @@ export function createApp(config = {}) {
|
|
|
770
1051
|
const run = (i) => {
|
|
771
1052
|
const mw = chain[i];
|
|
772
1053
|
if (!mw)
|
|
773
|
-
return dispatch(req, url, state, pending, ip, requestId, seen);
|
|
1054
|
+
return dispatch(req, url, state, pending, ip, requestId, seen, rest);
|
|
774
1055
|
return mw({ req, url, state, requestId }, () => run(i + 1));
|
|
775
1056
|
};
|
|
776
1057
|
let response;
|
|
@@ -788,10 +1069,12 @@ export function createApp(config = {}) {
|
|
|
788
1069
|
if (!probe) {
|
|
789
1070
|
const elapsed = performance.now() - started;
|
|
790
1071
|
const method = req.method.toUpperCase();
|
|
791
|
-
|
|
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);
|
|
792
1075
|
if (logging.requests) {
|
|
793
1076
|
const durationMs = Math.round(elapsed * 10) / 10;
|
|
794
|
-
logger.info(`${method} ${url.pathname} ${
|
|
1077
|
+
logger.info(`${method} ${url.pathname} ${status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status, durationMs });
|
|
795
1078
|
}
|
|
796
1079
|
}
|
|
797
1080
|
if (secureHeaders.length) {
|
|
@@ -825,6 +1108,8 @@ export function createApp(config = {}) {
|
|
|
825
1108
|
jobs,
|
|
826
1109
|
events,
|
|
827
1110
|
cache,
|
|
1111
|
+
storage,
|
|
1112
|
+
sockets: { broadcast: socketHub.broadcast, count: socketHub.count },
|
|
828
1113
|
get closing() {
|
|
829
1114
|
return closing;
|
|
830
1115
|
},
|
|
@@ -833,6 +1118,12 @@ export function createApp(config = {}) {
|
|
|
833
1118
|
metrics?.inFlight(1);
|
|
834
1119
|
try {
|
|
835
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;
|
|
836
1127
|
if (rest.length)
|
|
837
1128
|
keepEventStreamOpen(rest[0], req, response);
|
|
838
1129
|
return response;
|
|
@@ -854,6 +1145,13 @@ export function createApp(config = {}) {
|
|
|
854
1145
|
body = JSON.stringify(options.json);
|
|
855
1146
|
headers["content-type"] ??= "application/json";
|
|
856
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
|
+
}
|
|
857
1155
|
const req = new Request(url, { method: (options.method ?? "GET").toUpperCase(), headers, body });
|
|
858
1156
|
if ("as" in options)
|
|
859
1157
|
testUsers.set(req, options.as ?? null);
|
|
@@ -897,6 +1195,9 @@ export function createApp(config = {}) {
|
|
|
897
1195
|
if (delayMs > 0)
|
|
898
1196
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
899
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();
|
|
900
1201
|
const deadline = Date.now() + timeoutMs;
|
|
901
1202
|
let workersStopped = false;
|
|
902
1203
|
const drained = Promise.all([whenIdle(), stopWorkers(jobs).then(() => void (workersStopped = true))]);
|
|
@@ -912,6 +1213,7 @@ export function createApp(config = {}) {
|
|
|
912
1213
|
if (!(await within(server.close(), Math.max(0, deadline - Date.now()))))
|
|
913
1214
|
forceClosers?.get(server)?.();
|
|
914
1215
|
}));
|
|
1216
|
+
await socketHub.stop().catch((error) => logger.error("WebSocket broadcasts: unsubscribing failed", error));
|
|
915
1217
|
await db.$close();
|
|
916
1218
|
})();
|
|
917
1219
|
return closed;
|
|
@@ -1004,4 +1306,25 @@ function parseOrThrow(schema, value, where, coerce) {
|
|
|
1004
1306
|
throw new ValidationError(result.issues, where);
|
|
1005
1307
|
return result.value;
|
|
1006
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
|
+
}
|
|
1007
1330
|
//# sourceMappingURL=app.js.map
|