@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
@@ -0,0 +1,39 @@
1
+ /** The part of a postgres.js `sql` instance or reserved connection Inferlo's queries use. */
2
+ export interface PostgresJsQueryable {
3
+ unsafe(query: string, parameters?: any[]): PromiseLike<unknown[] & {
4
+ count?: number | null;
5
+ }>;
6
+ }
7
+ /** The parts of a postgres.js `sql` instance Inferlo uses: queries, and reserve() for transactions. */
8
+ export interface PostgresJsSql extends PostgresJsQueryable {
9
+ reserve(): Promise<PostgresJsQueryable & {
10
+ release(): void;
11
+ }>;
12
+ }
13
+ /**
14
+ * What `db.$pgClient()` returns with postgresJsPool(): `sql` is the pool's instance, or inside db.$transaction
15
+ * the transaction's reserved connection: `tx.$pgClient<PostgresJsClient<ReservedSql>>()` with
16
+ * `import type { ReservedSql } from "postgres"`.
17
+ */
18
+ export interface PostgresJsClient<S = PostgresJsQueryable> {
19
+ /** postgres.js on the pool, or inside db.$transaction the transaction's reserved connection: `await sql\`...\``. */
20
+ readonly sql: S;
21
+ query(text: string, params?: unknown[]): Promise<{
22
+ rows: Record<string, unknown>[];
23
+ rowCount: number | null;
24
+ }>;
25
+ }
26
+ /** The pool that postgres({ pool }) takes. */
27
+ export interface PostgresJsPool<S = PostgresJsSql> extends PostgresJsClient<S> {
28
+ connect(): Promise<PostgresJsClient & {
29
+ release(): void;
30
+ }>;
31
+ /** Does nothing: the app created `sql` and ends it with `await sql.end()` after `app.close()`. */
32
+ end(): Promise<void>;
33
+ }
34
+ /**
35
+ * A postgres.js `sql` instance as the pool of Inferlo's Postgres adapter: `postgres({ pool: postgresJsPool(sql) })`.
36
+ * Inferlo's queries and transactions use postgres.js's connections; `tx.$pgClient<PostgresJsClient>()` gives the
37
+ * transaction's connection as `sql`.
38
+ */
39
+ export declare function postgresJsPool<S extends PostgresJsSql>(sql: S): PostgresJsPool<S>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * postgres.js (the `postgres` package) as the connection pool of Inferlo's Postgres adapter, so an app that uses
3
+ * postgres.js keeps one pool and writes postgres.js queries and Inferlo models in one transaction.
4
+ *
5
+ * import postgresJs from "postgres";
6
+ * import { postgres } from "@browserlessapi/inferlo/postgres";
7
+ * import { postgresJsPool } from "@browserlessapi/inferlo/postgres-js";
8
+ *
9
+ * export const sql = postgresJs(process.env.DATABASE_URL!, { max: 10 });
10
+ * createApp({ db: postgres({ pool: postgresJsPool(sql) }), ... });
11
+ *
12
+ * db.$transaction(async (tx) => {
13
+ * const { sql } = await tx.$pgClient<PostgresJsClient>(); // the transaction's connection
14
+ * await sql`insert into crm_contacts ${sql({ email })}`;
15
+ * await tx.contact_notes.insert({ ... });
16
+ * });
17
+ *
18
+ * postgres.js is an optional peer dependency (3.4 and later); this entry point never imports it, it only uses the
19
+ * `sql` instance it is given. No runtime dependencies.
20
+ */
21
+ import { InferloError } from "./errors.js";
22
+ /** postgres.js errors name the table and constraint differently from pg; Inferlo's errors read pg's names. */
23
+ function asPgError(error) {
24
+ if (error && typeof error === "object") {
25
+ const e = error;
26
+ if (e.table === undefined && typeof e.table_name === "string")
27
+ e.table = e.table_name;
28
+ if (e.constraint === undefined && typeof e.constraint_name === "string")
29
+ e.constraint = e.constraint_name;
30
+ }
31
+ return error;
32
+ }
33
+ function client(sql) {
34
+ return {
35
+ sql,
36
+ async query(text, params = []) {
37
+ try {
38
+ // Inferlo builds each statement from model definitions and sends every value as a parameter.
39
+ const result = await sql.unsafe(text, params);
40
+ return { rows: [...result], rowCount: typeof result.count === "number" ? result.count : null };
41
+ }
42
+ catch (error) {
43
+ throw asPgError(error);
44
+ }
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * A postgres.js `sql` instance as the pool of Inferlo's Postgres adapter: `postgres({ pool: postgresJsPool(sql) })`.
50
+ * Inferlo's queries and transactions use postgres.js's connections; `tx.$pgClient<PostgresJsClient>()` gives the
51
+ * transaction's connection as `sql`.
52
+ */
53
+ export function postgresJsPool(sql) {
54
+ const given = sql;
55
+ const parts = given;
56
+ if (typeof given !== "function" || typeof parts.unsafe !== "function" || typeof parts.reserve !== "function") {
57
+ throw new InferloError("IL4029", "postgresJsPool(sql) needs a postgres.js sql instance (3.4 or later).", {
58
+ fix: 'Create it with postgres.js and pass it: import postgresJs from "postgres"; const sql = postgresJs(process.env.DATABASE_URL); postgres({ pool: postgresJsPool(sql) }).',
59
+ });
60
+ }
61
+ return {
62
+ ...client(sql),
63
+ async connect() {
64
+ // A reserved connection: postgres.js keeps it for this transaction until release().
65
+ const reserved = await sql.reserve();
66
+ return { ...client(reserved), release: () => reserved.release() };
67
+ },
68
+ async end() { },
69
+ };
70
+ }
71
+ //# sourceMappingURL=postgres-js.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres-js.js","sourceRoot":"","sources":["../src/postgres-js.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA+B3C,8GAA8G;AAC9G,SAAS,SAAS,CAAC,KAAc;IAC/B,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACvC,MAAM,CAAC,GAAG,KAAmG,CAAC;QAC9G,IAAI,CAAC,CAAC,KAAK,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,UAAU,KAAK,QAAQ;YAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,UAAU,CAAC;QACtF,IAAI,CAAC,CAAC,UAAU,KAAK,SAAS,IAAI,OAAO,CAAC,CAAC,eAAe,KAAK,QAAQ;YAAE,CAAC,CAAC,UAAU,GAAG,CAAC,CAAC,eAAe,CAAC;IAC5G,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,MAAM,CAAgC,GAAM;IACnD,OAAO;QACL,GAAG;QACH,KAAK,CAAC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,EAAE;YAC3B,IAAI,CAAC;gBACH,6FAA6F;gBAC7F,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;gBAC9C,OAAO,EAAE,IAAI,EAAE,CAAC,GAAG,MAAM,CAA8B,EAAE,QAAQ,EAAE,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;YAC9H,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,SAAS,CAAC,KAAK,CAAC,CAAC;YACzB,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAA0B,GAAM;IAC5D,MAAM,KAAK,GAAY,GAAG,CAAC;IAC3B,MAAM,KAAK,GAAG,KAAgD,CAAC;IAC/D,IAAI,OAAO,KAAK,KAAK,UAAU,IAAI,OAAO,KAAK,CAAC,MAAM,KAAK,UAAU,IAAI,OAAO,KAAK,CAAC,OAAO,KAAK,UAAU,EAAE,CAAC;QAC7G,MAAM,IAAI,YAAY,CAAC,QAAQ,EAAE,sEAAsE,EAAE;YACvG,GAAG,EAAE,uKAAuK;SAC7K,CAAC,CAAC;IACL,CAAC;IACD,OAAO;QACL,GAAG,MAAM,CAAC,GAAG,CAAC;QACd,KAAK,CAAC,OAAO;YACX,oFAAoF;YACpF,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,OAAO,EAAE,CAAC;YACrC,OAAO,EAAE,GAAG,MAAM,CAAC,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC;QACpE,CAAC;QACD,KAAK,CAAC,GAAG,KAAI,CAAC;KACf,CAAC;AACJ,CAAC"}
@@ -0,0 +1,24 @@
1
+ import type { RedisClient } from "./redis.ts";
2
+ import type { SocketRelay } from "./sockets.ts";
3
+ /**
4
+ * A second, connected Redis client for receiving broadcasts: a client in subscriber mode cannot send other
5
+ * commands, so it cannot be the createApp({ redis }) client. With the redis package: await redis.duplicate().connect().
6
+ * For ioredis, pass { subscribe: async (channel, listener) => { sub.on("message", (c, m) => c === channel && listener(m)); await sub.subscribe(channel); }, unsubscribe: (channel) => sub.unsubscribe(channel) }.
7
+ */
8
+ export interface RedisSubscriber {
9
+ subscribe(channel: string, listener: (message: string, channel: string) => void): Promise<unknown>;
10
+ unsubscribe(channel: string): Promise<unknown>;
11
+ }
12
+ export interface SocketsConfig {
13
+ /**
14
+ * How broadcasts reach the sockets of other instances. Default: none, sockets.broadcast reaches the sockets of this
15
+ * process only. "redis": Redis Pub/Sub in the Redis of createApp({ redis }) (needs name and subscriber), so a
16
+ * broadcast from any instance or from inferlo worker reaches every instance's sockets in the room.
17
+ */
18
+ transport?: "redis";
19
+ /** With transport "redis": a second connected Redis client that receives broadcasts (await redis.duplicate().connect()). */
20
+ subscriber?: RedisSubscriber;
21
+ }
22
+ /** Checks createApp({ sockets }) at startup and returns the subscriber for transport "redis". Raises IL6036. */
23
+ export declare function checkSocketsConfig(config: unknown, redis: RedisClient | undefined, name: string | undefined): RedisSubscriber | undefined;
24
+ export declare function redisSocketRelay(client: RedisClient, subscriber: RedisSubscriber, channel: string, onError: (message: string, error?: unknown) => void): SocketRelay;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * WebSocket broadcasts across instances over Redis Pub/Sub:
3
+ * createApp({ name: "boards", redis, sockets: { transport: "redis", subscriber: await redis.duplicate().connect() } }).
4
+ *
5
+ * Every broadcast is sent to the sockets of this process and published on one channel per app,
6
+ * inferlo:<app name>:sockets, as JSON { o: instance id, k: room key (with the tenant), d: message text }. Each
7
+ * instance with open sockets subscribes to the channel (on its first WebSocket connection) and sends what other
8
+ * instances published to its own sockets in that room. Pub/Sub keeps nothing: an instance that is not subscribed
9
+ * when a broadcast is published does not get it, as with sockets that join a room after a broadcast.
10
+ */
11
+ import { InferloError } from "./errors.js";
12
+ const FIX = 'Use createApp({ name: "boards", redis, sockets: { transport: "redis", subscriber: await redis.duplicate().connect() } }), with redis = await createClient({ url: process.env.REDIS_URL }).connect().';
13
+ function invalid(message) {
14
+ return new InferloError("IL6036", message, { fix: FIX });
15
+ }
16
+ /** Checks createApp({ sockets }) at startup and returns the subscriber for transport "redis". Raises IL6036. */
17
+ export function checkSocketsConfig(config, redis, name) {
18
+ if (config === undefined)
19
+ return undefined;
20
+ if (typeof config !== "object" || config === null || Array.isArray(config))
21
+ throw invalid("createApp({ sockets }) must be an object.");
22
+ const { transport, subscriber } = config;
23
+ const unknown = Object.keys(config).filter((key) => key !== "transport" && key !== "subscriber");
24
+ if (unknown.length)
25
+ throw invalid(`Unknown sockets option ${unknown.map((key) => `"${key}"`).join(", ")}.`);
26
+ if (transport === undefined) {
27
+ if (subscriber !== undefined)
28
+ throw invalid('sockets.subscriber is only used with transport "redis".');
29
+ return undefined;
30
+ }
31
+ if (transport !== "redis")
32
+ throw invalid(`Invalid sockets transport ${JSON.stringify(transport)}: the only transport is "redis".`);
33
+ if (!redis)
34
+ throw invalid('sockets transport "redis" needs a Redis client, but createApp has no redis option.');
35
+ // Instances find each other by the channel named after the app, so apps sharing one Redis must have names.
36
+ if (!name)
37
+ throw invalid('sockets transport "redis" needs createApp({ name }): the instances of an app share one channel, named after the app.');
38
+ if (typeof subscriber !== "object" || subscriber === null || typeof subscriber.subscribe !== "function" || typeof subscriber.unsubscribe !== "function") {
39
+ throw invalid('sockets transport "redis" needs a subscriber: a second connected Redis client with subscribe and unsubscribe.');
40
+ }
41
+ if (subscriber === redis)
42
+ throw invalid("sockets.subscriber must be a second Redis client, not the createApp({ redis }) client: a subscribed client cannot send other commands.");
43
+ return subscriber;
44
+ }
45
+ export function redisSocketRelay(client, subscriber, channel, onError) {
46
+ const instance = crypto.randomUUID();
47
+ let listener;
48
+ return {
49
+ async publish(key, text) {
50
+ await client.sendCommand(["PUBLISH", channel, JSON.stringify({ o: instance, k: key, d: text })]);
51
+ },
52
+ async start(deliver) {
53
+ listener = (message) => {
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(String(message));
57
+ }
58
+ catch (error) {
59
+ return onError(`ignored a message on ${channel} that is not JSON`, error);
60
+ }
61
+ if (parsed.o === instance)
62
+ return;
63
+ if (typeof parsed.k !== "string" || typeof parsed.d !== "string")
64
+ return onError(`ignored a message on ${channel} without a room and data`);
65
+ deliver(parsed.k, parsed.d);
66
+ };
67
+ await subscriber.subscribe(channel, (message) => listener?.(message));
68
+ },
69
+ async stop() {
70
+ listener = undefined;
71
+ await subscriber.unsubscribe(channel);
72
+ },
73
+ };
74
+ }
75
+ //# sourceMappingURL=redis-sockets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis-sockets.js","sourceRoot":"","sources":["../src/redis-sockets.ts"],"names":[],"mappings":"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"}
package/dist/route.d.ts CHANGED
@@ -9,15 +9,17 @@
9
9
  * Secure by default: routes require a signed-in user unless you set auth: "public".
10
10
  */
11
11
  import type { Deprecation } from "./deprecation.ts";
12
- import type { AI } from "./ai/index.ts";
12
+ import type { AI, StreamResult, Usage } from "./ai/index.ts";
13
13
  import type { Db } from "./db/table.ts";
14
14
  import type { Events } from "./events.ts";
15
15
  import type { Jobs } from "./jobs.ts";
16
16
  import type { AnyModel } from "./model.ts";
17
17
  import type { Audit } from "./audit.ts";
18
18
  import type { Cache, RouteCache } from "./cache.ts";
19
+ import type { Storage } from "./storage.ts";
19
20
  import type { PermissionInput, Permissions } from "./permissions.ts";
20
21
  import type { RateLimit } from "./rate-limit.ts";
22
+ import type { Sockets } from "./sockets.ts";
21
23
  import type { AnySchema, Infer, InferInput, ObjectSchema } from "./schema.ts";
22
24
  /**
23
25
  * Register your app's models and user type once, so every handler is fully typed:
@@ -64,7 +66,7 @@ export interface RouteOptions<B extends AnySchema | undefined, Q extends ObjectS
64
66
  * resource, call access.authorize(name, { user, ticket }) in the handler instead. Typed from Register's permissions.
65
67
  */
66
68
  permission?: AppPermission;
67
- /** Request body schema (JSON or form). */
69
+ /** Request body schema: JSON, a URL-encoded form, or multipart/form-data when it has t.file() fields. */
68
70
  body?: B;
69
71
  /** Query string schema. Strings are converted to numbers and booleans automatically. */
70
72
  query?: Q;
@@ -117,6 +119,10 @@ export interface Context<Body, Query, Params, User> {
117
119
  events: Events;
118
120
  /** The app cache: await cache.getOrSet(key, { ttlMs: 60_000, tags: ["tickets"] }, compute), cache.invalidate("tickets"). */
119
121
  cache: Cache;
122
+ /** Files (createApp({ storage })): await storage.put(`avatars/${user.id}.png`, body.avatar), storage.get(key), storage.delete(key). */
123
+ storage: Storage;
124
+ /** Send to WebSocket rooms: await sockets.broadcast(`project:${id}`, data), to the user's tenant's room with a tenant. */
125
+ sockets: Sockets;
120
126
  req: Request;
121
127
  url: URL;
122
128
  headers: Headers;
@@ -162,6 +168,8 @@ export interface RouteDef {
162
168
  name?: string;
163
169
  /** API version ("v1") when the route comes from apiVersion("v1", routes). */
164
170
  readonly version?: string;
171
+ /** Set for WebSocket routes from route.ws(): the handler runs once per message instead of once per request. */
172
+ readonly websocket?: true;
165
173
  }
166
174
  export declare class RouteBuilder<Path extends string, B extends AnySchema | undefined, Q extends ObjectSchema<any> | undefined, P extends ObjectSchema<any> | undefined, A> {
167
175
  readonly method: Method;
@@ -174,6 +182,92 @@ export declare class RouteBuilder<Path extends string, B extends AnySchema | und
174
182
  readonly $input: RouteInput<Path, B, Q, P>;
175
183
  };
176
184
  }
185
+ export interface SocketRouteOptions<M extends AnySchema | undefined, Q extends ObjectSchema<any> | undefined, P extends ObjectSchema<any> | undefined> {
186
+ /** Who may connect. Default "user" (signed in), checked once when the socket opens. */
187
+ auth?: Auth;
188
+ /** A permission from createApp({ permissions }) the user must have to connect (checked with { user }). */
189
+ permission?: AppPermission;
190
+ /** Schema of each message (JSON text). A message that does not match gets an IL1001 error message back. */
191
+ message?: M;
192
+ /** Query string schema of the connection URL. */
193
+ query?: Q;
194
+ /** Path parameter schema. Without it, path params are strings. */
195
+ params?: P;
196
+ /**
197
+ * Limit connections and messages per user, tenant or IP: { max: 30, per: "minute", by: "user" }. Opening the
198
+ * socket and every message count. A refused connection answers 429; a message over the limit gets an IL2005 error message.
199
+ */
200
+ rateLimit?: RateLimit;
201
+ /** One line shown in describe. */
202
+ summary?: string;
203
+ description?: string;
204
+ tags?: string[];
205
+ }
206
+ /** The open connection, in a WebSocket handler. */
207
+ export interface Socket {
208
+ /** A random id for this connection. */
209
+ readonly id: string;
210
+ /** Send a message to this client, as JSON. Does nothing once the socket is closed. */
211
+ send(data: unknown): void;
212
+ /** Close the connection: code 1000 (normal) by default. */
213
+ close(code?: number, reason?: string): void;
214
+ /**
215
+ * Join a room, so sockets.broadcast(room, data) reaches this client. Rooms are per tenant: with a signed-in user
216
+ * who has a tenant, the room is that tenant's. A closed socket leaves every room.
217
+ */
218
+ join(room: string): void;
219
+ /** Leave a room joined with socket.join(room). */
220
+ leave(room: string): void;
221
+ /**
222
+ * Stream a model's answer to this client: await socket.stream(ai.stream({ model: "fast", prompt })) sends
223
+ * { type: "text", text } for each chunk as it arrives, then { type: "done", usage }, and resolves with the full
224
+ * text and usage. Streams from ctx.ai stop when the socket closes (ctx.signal).
225
+ */
226
+ stream(result: StreamResult): Promise<{
227
+ text: string;
228
+ usage: Usage;
229
+ }>;
230
+ }
231
+ export interface SocketContext<Message, Query, Params, User> {
232
+ /** This message, validated against the route's message schema. */
233
+ message: Message;
234
+ query: Query;
235
+ params: Params;
236
+ /** The user who opened the socket (null only on public routes with no session). */
237
+ user: User;
238
+ socket: Socket;
239
+ db: AppDb;
240
+ ai: AI;
241
+ jobs: Jobs;
242
+ events: Events;
243
+ cache: Cache;
244
+ /** Files (createApp({ storage })), the same as ctx.storage in routes. */
245
+ storage: Storage;
246
+ /** Send to WebSocket rooms, this client's included when it joined: await sockets.broadcast(room, data). */
247
+ sockets: Sockets;
248
+ /** Write an audit log entry with the user and this connection's request id. */
249
+ audit: Audit;
250
+ /** Aborted when the socket closes. ctx.ai calls use it automatically. */
251
+ signal: AbortSignal;
252
+ /** The id of the request that opened the socket, also in its log entries. */
253
+ requestId: string;
254
+ /** URL and headers of the request that opened the socket. */
255
+ url: URL;
256
+ headers: Headers;
257
+ }
258
+ export declare class SocketRouteBuilder<Path extends string, M extends AnySchema | undefined, Q extends ObjectSchema<any> | undefined, P extends ObjectSchema<any> | undefined, A> {
259
+ readonly path: Path;
260
+ readonly options: SocketRouteOptions<M, Q, P>;
261
+ constructor(path: Path, options: SocketRouteOptions<M, Q, P>);
262
+ /**
263
+ * The message handler, run for each message in the order they arrive. Return data to send it back as JSON, or
264
+ * nothing; call ctx.socket.send(data) to send more. A thrown error is sent back as an error message (the socket stays open).
265
+ */
266
+ handle<R>(handler: Handler<SocketContext<M extends AnySchema ? Infer<M> : unknown, Q extends AnySchema ? Infer<Q> : Record<string, string | undefined>, P extends AnySchema ? Infer<P> : PathParams<Path>, UserFor<A>>, R>): RouteDef & {
267
+ readonly $message: M extends AnySchema ? InferInput<M> : unknown;
268
+ readonly $reply: Awaited<R>;
269
+ };
270
+ }
177
271
  export declare const route: {
178
272
  get: <const Path extends string, B extends AnySchema | undefined = undefined, Q extends ObjectSchema<any> | undefined = undefined, P extends ObjectSchema<any> | undefined = undefined, const A extends Auth = "user">(path: Path, options?: RouteOptions<B, Q, P> & {
179
273
  auth?: A;
@@ -190,6 +284,13 @@ export declare const route: {
190
284
  delete: <const Path extends string, B extends AnySchema | undefined = undefined, Q extends ObjectSchema<any> | undefined = undefined, P extends ObjectSchema<any> | undefined = undefined, const A extends Auth = "user">(path: Path, options?: RouteOptions<B, Q, P> & {
191
285
  auth?: A;
192
286
  }) => RouteBuilder<Path, B, Q, P, A>;
287
+ /**
288
+ * A WebSocket route: route.ws("/chat", { message: t.object({ text: t.string() }) }).handle(({ message, user, socket }) => ...).
289
+ * Same auth, permissions and rate limits as HTTP routes; each JSON message is validated and handled in order.
290
+ */
291
+ ws: <const Path extends string, M extends AnySchema | undefined = undefined, Q extends ObjectSchema<any> | undefined = undefined, P extends ObjectSchema<any> | undefined = undefined, const A extends Auth = "user">(path: Path, options?: SocketRouteOptions<M, Q, P> & {
292
+ auth?: A;
293
+ }) => SocketRouteBuilder<Path, M, Q, P, A>;
193
294
  };
194
295
  export declare function isRoute(value: unknown): value is RouteDef;
195
296
  /** The routes in route sources: routes, arrays and modules (import * as tickets), named after their exports. */
package/dist/route.js CHANGED
@@ -15,12 +15,39 @@ export class RouteBuilder {
15
15
  function builder(method) {
16
16
  return (path, options = {}) => new RouteBuilder(method, path, options);
17
17
  }
18
+ export class SocketRouteBuilder {
19
+ path;
20
+ options;
21
+ constructor(path, options) {
22
+ this.path = path;
23
+ this.options = options;
24
+ }
25
+ /**
26
+ * The message handler, run for each message in the order they arrive. Return data to send it back as JSON, or
27
+ * nothing; call ctx.socket.send(data) to send more. A thrown error is sent back as an error message (the socket stays open).
28
+ */
29
+ handle(handler) {
30
+ return {
31
+ kind: "inferlo.route",
32
+ method: "GET",
33
+ path: this.path,
34
+ options: this.options,
35
+ handler: handler,
36
+ websocket: true,
37
+ };
38
+ }
39
+ }
18
40
  export const route = {
19
41
  get: builder("GET"),
20
42
  post: builder("POST"),
21
43
  put: builder("PUT"),
22
44
  patch: builder("PATCH"),
23
45
  delete: builder("DELETE"),
46
+ /**
47
+ * A WebSocket route: route.ws("/chat", { message: t.object({ text: t.string() }) }).handle(({ message, user, socket }) => ...).
48
+ * Same auth, permissions and rate limits as HTTP routes; each JSON message is validated and handled in order.
49
+ */
50
+ ws: (path, options = {}) => new SocketRouteBuilder(path, options),
24
51
  };
25
52
  export function isRoute(value) {
26
53
  return typeof value === "object" && value !== null && value.kind === "inferlo.route";
package/dist/route.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"route.js","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":"AA2KA,MAAM,OAAO,YAAY;IACd,MAAM,CAAS;IACf,IAAI,CAAO;IACX,OAAO,CAAwB;IAExC,YAAY,MAAc,EAAE,IAAU,EAAE,OAA8B;QACpE,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,OAAO,GAAG,OAAO,CAAC;IACzB,CAAC;IAED,qFAAqF;IACrF,MAAM,CACJ,OAQC;QAED,OAAO,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,OAAuC,EAG5I,CAAC;IACJ,CAAC;CACF;AAED,SAAS,OAAO,CAAC,MAAc;IAC7B,OAAO,CAOL,IAAU,EACV,UAAgD,EAAE,EAClD,EAAE,CAAC,IAAI,YAAY,CAAmB,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;AACjE,CAAC;AAED,MAAM,CAAC,MAAM,KAAK,GAAG;IACnB,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC;IACnB,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC,KAAK,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC;IACvB,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC;CAC1B,CAAC;AAEF,MAAM,UAAU,OAAO,CAAC,KAAc;IACpC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAK,KAAkB,CAAC,IAAI,KAAK,eAAe,CAAC;AACrG,CAAC;AAED,gHAAgH;AAChH,MAAM,UAAU,aAAa,CAAC,OAAgB;IAC5C,MAAM,GAAG,GAAe,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,IAAI,GAAG,EAAW,CAAC;IAChC,MAAM,KAAK,GAAG,CAAC,MAAe,EAAE,IAAa,EAAE,EAAE;QAC/C,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC;YAAE,OAAO;QACtE,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACjB,IAAI,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YACpB,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC;YAC9D,OAAO;QACT,CAAC;QACD,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;YAC1B,KAAK,MAAM,IAAI,IAAI,MAAM;gBAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YACvC,OAAO;QACT,CAAC;QACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC;YAAE,IAAI,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;gBAAE,KAAK,CAAC,KAAK,EAAE,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;IACnJ,CAAC,CAAC;IACF,KAAK,CAAC,OAAO,CAAC,CAAC;IACf,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"route.js","sourceRoot":"","sources":["../src/route.ts"],"names":[],"mappings":"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"}
package/dist/schema.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * type User = Infer<typeof User>
7
7
  */
8
8
  import { InferloError } from "./errors.ts";
9
- export type Kind = "string" | "text" | "email" | "url" | "uuid" | "ref" | "number" | "int" | "boolean" | "date" | "enum" | "literal" | "json" | "array" | "object" | "any";
9
+ export type Kind = "string" | "text" | "email" | "url" | "uuid" | "ref" | "number" | "int" | "boolean" | "date" | "enum" | "literal" | "json" | "array" | "object" | "file" | "any";
10
10
  export interface Def {
11
11
  kind: Kind;
12
12
  optional: boolean;
@@ -17,6 +17,8 @@ export interface Def {
17
17
  max?: number;
18
18
  pattern?: RegExp;
19
19
  values?: readonly (string | number | boolean)[];
20
+ /** t.file(): accepted media types ("image/png", "image/*"); empty means any type. */
21
+ fileTypes?: readonly string[];
20
22
  item?: AnySchema;
21
23
  shape?: Shape;
22
24
  unknownKeys?: "strict" | "strip";
@@ -159,6 +161,24 @@ export declare const t: {
159
161
  onDelete?: "restrict" | "cascade" | "set null";
160
162
  }) => RefSchema<Table, string, string>;
161
163
  any: () => Schema<unknown, unknown>;
164
+ /**
165
+ * An uploaded file in a multipart/form-data body: body: t.object({ avatar: t.file({ maxBytes: 2_000_000, types: ["image/png", "image/jpeg"] }) }).
166
+ * The handler gets a web File. maxBytes defaults to 10 MiB; types defaults to any type. Only in route bodies.
167
+ */
168
+ file: (options?: FileOptions) => Schema<File, File>;
162
169
  };
170
+ /** Options of t.file(). */
171
+ export interface FileOptions {
172
+ /** Largest accepted file in bytes. Default 10 MiB (10485760). */
173
+ maxBytes?: number;
174
+ /** Accepted media types, exact ("image/png") or by family ("image/*"). Default: any type. */
175
+ types?: readonly string[];
176
+ }
177
+ /** Default t.file() limit: 10 MiB. */
178
+ export declare const DEFAULT_FILE_MAX_BYTES: number;
179
+ /** Whether a media type ("image/png; x=1") is one of the accepted types (exact or "image/*"). */
180
+ export declare function fileTypeAccepted(type: string, accepted: readonly string[]): boolean;
181
+ /** Whether a schema has a t.file() anywhere in it. */
182
+ export declare function containsFile(schema: AnySchema): boolean;
163
183
  export declare function toJSONSchema(schema: AnySchema): JSONSchema;
164
184
  export {};
package/dist/schema.js CHANGED
@@ -179,7 +179,58 @@ export const t = {
179
179
  /** Id of a row in another model's table: t.ref("users"). */
180
180
  ref: (table, options = {}) => new RefSchema(base("ref", { refTable: table, onDelete: options.onDelete ?? "restrict" })),
181
181
  any: () => new Schema(base("any", { optional: true })),
182
+ /**
183
+ * An uploaded file in a multipart/form-data body: body: t.object({ avatar: t.file({ maxBytes: 2_000_000, types: ["image/png", "image/jpeg"] }) }).
184
+ * The handler gets a web File. maxBytes defaults to 10 MiB; types defaults to any type. Only in route bodies.
185
+ */
186
+ file: (options = {}) => new Schema(fileDef(options)),
182
187
  };
188
+ /** Default t.file() limit: 10 MiB. */
189
+ export const DEFAULT_FILE_MAX_BYTES = 10 * 1024 * 1024;
190
+ const MEDIA_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/([a-z0-9][a-z0-9!#$&^_.+-]*|\*)$/;
191
+ function fileDef(options) {
192
+ if (options === null || typeof options !== "object" || Array.isArray(options)) {
193
+ throw new InferloError("IL1004", "t.file() takes an options object.", { fix: 't.file({ maxBytes: 2_000_000, types: ["image/png", "image/jpeg"] }), or t.file() for any file up to 10 MiB.' });
194
+ }
195
+ for (const key of Object.keys(options)) {
196
+ if (key !== "maxBytes" && key !== "types") {
197
+ throw new InferloError("IL1004", `Unknown t.file() option "${key}".${didYouMean(key, ["maxBytes", "types"])}`, { fix: "t.file() takes maxBytes and types." });
198
+ }
199
+ }
200
+ const maxBytes = options.maxBytes ?? DEFAULT_FILE_MAX_BYTES;
201
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
202
+ throw new InferloError("IL1004", `t.file({ maxBytes }) must be a whole number of bytes above 0, got ${String(options.maxBytes)}.`, { fix: "Use t.file({ maxBytes: 5 * 1024 * 1024 }) for 5 MiB." });
203
+ }
204
+ const types = options.types ?? [];
205
+ if (!Array.isArray(types) || types.some((type) => typeof type !== "string" || !MEDIA_TYPE.test(type.toLowerCase()))) {
206
+ throw new InferloError("IL1004", `t.file({ types }) must be a list of media types such as "image/png" or "image/*", got ${JSON.stringify(options.types)}.`, { fix: 'Use t.file({ types: ["image/png", "image/jpeg"] }), or leave types out to accept any type.' });
207
+ }
208
+ return base("file", { max: maxBytes, fileTypes: types.map((type) => type.toLowerCase()) });
209
+ }
210
+ /** Whether a media type ("image/png; x=1") is one of the accepted types (exact or "image/*"). */
211
+ export function fileTypeAccepted(type, accepted) {
212
+ if (!accepted.length)
213
+ return true;
214
+ const actual = type.split(";")[0].trim().toLowerCase();
215
+ return accepted.some((a) => (a.endsWith("/*") ? actual.startsWith(a.slice(0, -1)) : actual === a));
216
+ }
217
+ /** Whether a schema has a t.file() anywhere in it. */
218
+ export function containsFile(schema) {
219
+ // Not a schema at all (a mistake reported by the caller's own checks): no files.
220
+ const def = schema?.def;
221
+ if (!def || typeof def !== "object")
222
+ return false;
223
+ if (def.kind === "file")
224
+ return true;
225
+ if (def.kind === "array")
226
+ return def.item !== undefined && containsFile(def.item);
227
+ if (def.kind === "object")
228
+ return Object.values(def.shape ?? {}).some(containsFile);
229
+ return false;
230
+ }
231
+ function isFile(value) {
232
+ return typeof Blob !== "undefined" && value instanceof Blob;
233
+ }
183
234
  // ---------------------------------------------------------------------------------------------
184
235
  // Validation
185
236
  // ---------------------------------------------------------------------------------------------
@@ -236,6 +287,18 @@ function check(schema, raw, path, issues, options) {
236
287
  case "any":
237
288
  case "json":
238
289
  return value;
290
+ case "file": {
291
+ if (!isFile(value))
292
+ return fail("invalid_type", `Expected a file, got ${describeType(value)}. Send the body as multipart/form-data.`);
293
+ if (def.max !== undefined && value.size > def.max)
294
+ return fail("file_too_large", `File is ${value.size} bytes, at most ${def.max} bytes are accepted.`);
295
+ if (def.min !== undefined && value.size < def.min)
296
+ return fail("file_too_small", `File must be at least ${def.min} bytes.`);
297
+ const types = def.fileTypes ?? [];
298
+ if (!fileTypeAccepted(value.type, types))
299
+ return fail("file_type", `File type ${JSON.stringify(value.type || "unknown")} is not accepted. Expected ${types.join(", ")}.`);
300
+ return value;
301
+ }
239
302
  case "string":
240
303
  case "text":
241
304
  case "email":
@@ -418,6 +481,13 @@ export function toJSONSchema(schema) {
418
481
  out.required = required;
419
482
  break;
420
483
  }
484
+ case "file":
485
+ // OpenAPI 3.1 file in a multipart body; the accepted types go in the request body's encoding.
486
+ out = { type: "string", format: "binary" };
487
+ if (def.fileTypes?.length === 1)
488
+ out.contentMediaType = def.fileTypes[0];
489
+ out["x-inferlo-max-bytes"] = def.max;
490
+ break;
421
491
  case "json":
422
492
  case "any":
423
493
  out = {};