@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.
Files changed (140) hide show
  1. package/README.md +9 -5
  2. package/dist/ai/ai-sdk.d.ts +48 -0
  3. package/dist/ai/ai-sdk.js +235 -0
  4. package/dist/ai/ai-sdk.js.map +1 -0
  5. package/dist/ai/index.d.ts +37 -3
  6. package/dist/ai/index.js +96 -7
  7. package/dist/ai/index.js.map +1 -1
  8. package/dist/ai/public.d.ts +5 -1
  9. package/dist/ai/public.js +2 -0
  10. package/dist/ai/public.js.map +1 -1
  11. package/dist/ai/ui.d.ts +47 -0
  12. package/dist/ai/ui.js +33 -0
  13. package/dist/ai/ui.js.map +1 -0
  14. package/dist/app.d.ts +55 -4
  15. package/dist/app.js +439 -39
  16. package/dist/app.js.map +1 -1
  17. package/dist/astro.d.ts +60 -0
  18. package/dist/astro.js +68 -0
  19. package/dist/astro.js.map +1 -0
  20. package/dist/audit.d.ts +8 -1
  21. package/dist/audit.js +9 -2
  22. package/dist/audit.js.map +1 -1
  23. package/dist/auth.d.ts +1 -0
  24. package/dist/auth.js +1 -0
  25. package/dist/auth.js.map +1 -1
  26. package/dist/authjs.d.ts +70 -0
  27. package/dist/authjs.js +94 -0
  28. package/dist/authjs.js.map +1 -0
  29. package/dist/azure.d.ts +66 -0
  30. package/dist/azure.js +119 -0
  31. package/dist/azure.js.map +1 -0
  32. package/dist/better-auth.d.ts +89 -0
  33. package/dist/better-auth.js +75 -0
  34. package/dist/better-auth.js.map +1 -0
  35. package/dist/cache.d.ts +13 -9
  36. package/dist/cache.js +11 -9
  37. package/dist/cache.js.map +1 -1
  38. package/dist/cli/main.js +5 -5
  39. package/dist/cli/main.js.map +1 -1
  40. package/dist/client-ip.d.ts +41 -1
  41. package/dist/client-ip.js +85 -1
  42. package/dist/client-ip.js.map +1 -1
  43. package/dist/cross-site.d.ts +14 -0
  44. package/dist/cross-site.js +56 -0
  45. package/dist/cross-site.js.map +1 -0
  46. package/dist/db/memory.js +2 -1
  47. package/dist/db/memory.js.map +1 -1
  48. package/dist/db/postgres.js +23 -6
  49. package/dist/db/postgres.js.map +1 -1
  50. package/dist/db/table.d.ts +37 -5
  51. package/dist/db/table.js +96 -15
  52. package/dist/db/table.js.map +1 -1
  53. package/dist/db/types.d.ts +2 -0
  54. package/dist/describe.d.ts +23 -5
  55. package/dist/describe.js +20 -2
  56. package/dist/describe.js.map +1 -1
  57. package/dist/disk-storage.d.ts +6 -0
  58. package/dist/disk-storage.js +96 -0
  59. package/dist/disk-storage.js.map +1 -0
  60. package/dist/events.d.ts +134 -0
  61. package/dist/events.js +196 -0
  62. package/dist/events.js.map +1 -0
  63. package/dist/files.d.ts +23 -0
  64. package/dist/files.js +168 -0
  65. package/dist/files.js.map +1 -0
  66. package/dist/index.d.ts +26 -8
  67. package/dist/index.js +10 -2
  68. package/dist/index.js.map +1 -1
  69. package/dist/jobs.d.ts +56 -10
  70. package/dist/jobs.js +71 -13
  71. package/dist/jobs.js.map +1 -1
  72. package/dist/jwt.d.ts +83 -0
  73. package/dist/jwt.js +312 -0
  74. package/dist/jwt.js.map +1 -0
  75. package/dist/langchain.d.ts +58 -0
  76. package/dist/langchain.js +220 -0
  77. package/dist/langchain.js.map +1 -0
  78. package/dist/model.d.ts +23 -6
  79. package/dist/model.js +41 -7
  80. package/dist/model.js.map +1 -1
  81. package/dist/oidc.d.ts +70 -0
  82. package/dist/oidc.js +379 -0
  83. package/dist/oidc.js.map +1 -0
  84. package/dist/openapi.js +17 -2
  85. package/dist/openapi.js.map +1 -1
  86. package/dist/passport.d.ts +54 -0
  87. package/dist/passport.js +68 -0
  88. package/dist/passport.js.map +1 -0
  89. package/dist/postgres-js.d.ts +39 -0
  90. package/dist/postgres-js.js +71 -0
  91. package/dist/postgres-js.js.map +1 -0
  92. package/dist/rate-limit.d.ts +5 -3
  93. package/dist/rate-limit.js +3 -3
  94. package/dist/rate-limit.js.map +1 -1
  95. package/dist/redis-events.d.ts +27 -0
  96. package/dist/redis-events.js +163 -0
  97. package/dist/redis-events.js.map +1 -0
  98. package/dist/redis-jobs.d.ts +24 -0
  99. package/dist/redis-jobs.js +203 -0
  100. package/dist/redis-jobs.js.map +1 -0
  101. package/dist/redis-sockets.d.ts +24 -0
  102. package/dist/redis-sockets.js +75 -0
  103. package/dist/redis-sockets.js.map +1 -0
  104. package/dist/redis.d.ts +30 -0
  105. package/dist/redis.js +106 -0
  106. package/dist/redis.js.map +1 -0
  107. package/dist/response-body.d.ts +6 -0
  108. package/dist/response-body.js +7 -0
  109. package/dist/response-body.js.map +1 -0
  110. package/dist/route.d.ts +117 -3
  111. package/dist/route.js +27 -0
  112. package/dist/route.js.map +1 -1
  113. package/dist/schema.d.ts +23 -3
  114. package/dist/schema.js +70 -0
  115. package/dist/schema.js.map +1 -1
  116. package/dist/serve.d.ts +24 -1
  117. package/dist/serve.js +200 -8
  118. package/dist/serve.js.map +1 -1
  119. package/dist/service-client.d.ts +91 -0
  120. package/dist/service-client.js +177 -0
  121. package/dist/service-client.js.map +1 -0
  122. package/dist/sessions.d.ts +2 -0
  123. package/dist/sessions.js +36 -4
  124. package/dist/sessions.js.map +1 -1
  125. package/dist/sockets.d.ts +58 -0
  126. package/dist/sockets.js +111 -0
  127. package/dist/sockets.js.map +1 -0
  128. package/dist/storage.d.ts +47 -0
  129. package/dist/storage.js +123 -0
  130. package/dist/storage.js.map +1 -0
  131. package/dist/tracing.d.ts +7 -3
  132. package/dist/tracing.js +8 -2
  133. package/dist/tracing.js.map +1 -1
  134. package/dist/websocket.d.ts +81 -0
  135. package/dist/websocket.js +251 -0
  136. package/dist/websocket.js.map +1 -0
  137. package/llms.txt +2 -0
  138. package/package.json +77 -5
  139. package/templates/app/AGENTS.md +9 -5
  140. 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 { collectCrons, collectJobs, createJobs, jobStoreFor, stopWorkers } from "./jobs.js";
18
- import { ValidationError } from "./schema.js";
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 new TextDecoder().decode(all);
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
- return new Response(JSON.stringify(data), { status, headers });
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: "database" }.' });
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
- const cache = createCache(cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries), cacheConfig.store);
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
- audit: (run) => createAudit(models, redactor, () => db, { userId: null, source: `job ${run.name}` }),
268
- store: jobStoreFor(config.jobQueue, adapter),
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
- }, collectCrons(jobSources));
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
- 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) => {
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
- const signal = AbortSignal.any([req.signal, timeout.signal]);
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 (or IP without one), after auth so failed sign-ins on private routes do not use it up.
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 who = (opts.rateLimit.by ?? "user") === "user" && user ? `user:${user.id}` : `ip:${ip}`;
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
- 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) {
500
814
  const text = method === "GET" || method === "HEAD" ? "" : await readBody(req, maxBody, timeout.signal);
501
- const type = req.headers.get("content-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") === "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
- return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal)));
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: createAudit(models, redactor, () => ctx.db, { userId: user?.id ?? null, source: `${def.method} ${def.path}`, requestId }),
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
- 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);
719
1075
  if (logging.requests) {
720
1076
  const durationMs = Math.round(elapsed * 10) / 10;
721
- 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 });
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[1];
731
- if (execution?.waitUntil)
732
- execution.waitUntil(all);
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
- return await handle(req, rest);
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: { redis: () => redis.ping() } }.");
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