@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.
Files changed (78) hide show
  1. package/README.md +3 -2
  2. package/dist/app.d.ts +24 -0
  3. package/dist/app.js +336 -13
  4. package/dist/app.js.map +1 -1
  5. package/dist/authjs.d.ts +70 -0
  6. package/dist/authjs.js +94 -0
  7. package/dist/authjs.js.map +1 -0
  8. package/dist/better-auth.d.ts +89 -0
  9. package/dist/better-auth.js +75 -0
  10. package/dist/better-auth.js.map +1 -0
  11. package/dist/cli/main.js +1 -1
  12. package/dist/cli/main.js.map +1 -1
  13. package/dist/client-ip.d.ts +5 -0
  14. package/dist/client-ip.js +5 -0
  15. package/dist/client-ip.js.map +1 -1
  16. package/dist/cross-site.d.ts +14 -0
  17. package/dist/cross-site.js +56 -0
  18. package/dist/cross-site.js.map +1 -0
  19. package/dist/db/postgres.js +19 -5
  20. package/dist/db/postgres.js.map +1 -1
  21. package/dist/describe.d.ts +9 -2
  22. package/dist/describe.js +11 -1
  23. package/dist/describe.js.map +1 -1
  24. package/dist/disk-storage.d.ts +6 -0
  25. package/dist/disk-storage.js +96 -0
  26. package/dist/disk-storage.js.map +1 -0
  27. package/dist/files.d.ts +23 -0
  28. package/dist/files.js +168 -0
  29. package/dist/files.js.map +1 -0
  30. package/dist/index.d.ts +15 -4
  31. package/dist/index.js +6 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/jobs.d.ts +10 -4
  34. package/dist/jobs.js +7 -2
  35. package/dist/jobs.js.map +1 -1
  36. package/dist/jwt.d.ts +83 -0
  37. package/dist/jwt.js +312 -0
  38. package/dist/jwt.js.map +1 -0
  39. package/dist/model.js +6 -1
  40. package/dist/model.js.map +1 -1
  41. package/dist/oidc.d.ts +70 -0
  42. package/dist/oidc.js +379 -0
  43. package/dist/oidc.js.map +1 -0
  44. package/dist/openapi.js +17 -2
  45. package/dist/openapi.js.map +1 -1
  46. package/dist/passport.d.ts +54 -0
  47. package/dist/passport.js +68 -0
  48. package/dist/passport.js.map +1 -0
  49. package/dist/postgres-js.d.ts +39 -0
  50. package/dist/postgres-js.js +71 -0
  51. package/dist/postgres-js.js.map +1 -0
  52. package/dist/redis-sockets.d.ts +24 -0
  53. package/dist/redis-sockets.js +75 -0
  54. package/dist/redis-sockets.js.map +1 -0
  55. package/dist/route.d.ts +103 -2
  56. package/dist/route.js +27 -0
  57. package/dist/route.js.map +1 -1
  58. package/dist/schema.d.ts +21 -1
  59. package/dist/schema.js +70 -0
  60. package/dist/schema.js.map +1 -1
  61. package/dist/serve.d.ts +18 -0
  62. package/dist/serve.js +137 -2
  63. package/dist/serve.js.map +1 -1
  64. package/dist/sessions.js +35 -4
  65. package/dist/sessions.js.map +1 -1
  66. package/dist/sockets.d.ts +58 -0
  67. package/dist/sockets.js +111 -0
  68. package/dist/sockets.js.map +1 -0
  69. package/dist/storage.d.ts +47 -0
  70. package/dist/storage.js +123 -0
  71. package/dist/storage.js.map +1 -0
  72. package/dist/websocket.d.ts +81 -0
  73. package/dist/websocket.js +251 -0
  74. package/dist/websocket.js.map +1 -0
  75. package/llms.txt +1 -0
  76. package/package.json +43 -3
  77. package/templates/app/AGENTS.md +5 -3
  78. 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 new TextDecoder().decode(all);
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
- const dispatch = async (req, url, state, pending, ip, requestId, seen) => {
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 by = opts.rateLimit.by ?? "user";
534
- const scope = by === "tenant" && user ? tenantScope(user) : "none";
535
- const who = typeof scope === "object" ? `tenant:${scope.tenant}` : by !== "ip" && user ? `user:${user.id}` : `ip:${ip}`;
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
- if (opts.body) {
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 = req.headers.get("content-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
- metrics?.request(method, seen.route, response.status, elapsed / 1000);
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} ${response.status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status: response.status, durationMs });
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