@browserlessapi/inferlo 0.0.0-stage → 0.3.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 (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +127 -2
  3. package/dist/ai/index.d.ts +96 -0
  4. package/dist/ai/index.js +327 -0
  5. package/dist/ai/index.js.map +1 -0
  6. package/dist/ai/providers.d.ts +97 -0
  7. package/dist/ai/providers.js +204 -0
  8. package/dist/ai/providers.js.map +1 -0
  9. package/dist/app.d.ts +94 -0
  10. package/dist/app.js +365 -0
  11. package/dist/app.js.map +1 -0
  12. package/dist/auth.d.ts +29 -0
  13. package/dist/auth.js +61 -0
  14. package/dist/auth.js.map +1 -0
  15. package/dist/cli/main.d.ts +2 -0
  16. package/dist/cli/main.js +467 -0
  17. package/dist/cli/main.js.map +1 -0
  18. package/dist/db/memory.d.ts +2 -0
  19. package/dist/db/memory.js +128 -0
  20. package/dist/db/memory.js.map +1 -0
  21. package/dist/db/migrate.d.ts +84 -0
  22. package/dist/db/migrate.js +355 -0
  23. package/dist/db/migrate.js.map +1 -0
  24. package/dist/db/postgres.d.ts +37 -0
  25. package/dist/db/postgres.js +338 -0
  26. package/dist/db/postgres.js.map +1 -0
  27. package/dist/db/table.d.ts +72 -0
  28. package/dist/db/table.js +177 -0
  29. package/dist/db/table.js.map +1 -0
  30. package/dist/db/types.d.ts +36 -0
  31. package/dist/db/types.js +2 -0
  32. package/dist/db/types.js.map +1 -0
  33. package/dist/describe.d.ts +56 -0
  34. package/dist/describe.js +64 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/env.d.ts +19 -0
  37. package/dist/env.js +72 -0
  38. package/dist/env.js.map +1 -0
  39. package/dist/errors.d.ts +40 -0
  40. package/dist/errors.js +99 -0
  41. package/dist/errors.js.map +1 -0
  42. package/dist/index.d.ts +26 -0
  43. package/dist/index.js +18 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/mcp.d.ts +18 -0
  46. package/dist/mcp.js +166 -0
  47. package/dist/mcp.js.map +1 -0
  48. package/dist/model.d.ts +36 -0
  49. package/dist/model.js +76 -0
  50. package/dist/model.js.map +1 -0
  51. package/dist/openapi.d.ts +6 -0
  52. package/dist/openapi.js +71 -0
  53. package/dist/openapi.js.map +1 -0
  54. package/dist/route.d.ts +125 -0
  55. package/dist/route.js +28 -0
  56. package/dist/route.js.map +1 -0
  57. package/dist/schema.d.ts +156 -0
  58. package/dist/schema.js +430 -0
  59. package/dist/schema.js.map +1 -0
  60. package/dist/serve.d.ts +11 -0
  61. package/dist/serve.js +83 -0
  62. package/dist/serve.js.map +1 -0
  63. package/dist/testing.d.ts +16 -0
  64. package/dist/testing.js +30 -0
  65. package/dist/testing.js.map +1 -0
  66. package/llms.txt +27 -0
  67. package/package.json +82 -4
  68. package/templates/app/AGENTS.md +53 -0
  69. package/templates/app/CLAUDE.md +1 -0
  70. package/templates/app/README.md +14 -0
  71. package/templates/app/_env.example +5 -0
  72. package/templates/app/_gitignore +3 -0
  73. package/templates/app/package.json.tpl +31 -0
  74. package/templates/app/src/app.ts +38 -0
  75. package/templates/app/src/models.ts +31 -0
  76. package/templates/app/src/routes/health.ts +3 -0
  77. package/templates/app/src/routes/notes.ts +56 -0
  78. package/templates/app/src/server.ts +16 -0
  79. package/templates/app/test/notes.test.ts +50 -0
  80. package/templates/app/tsconfig.json +17 -0
package/package.json CHANGED
@@ -1,6 +1,84 @@
1
1
  {
2
2
  "name": "@browserlessapi/inferlo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "The TypeScript backend framework with built-in AI model support: Anthropic, OpenAI, Gemini, Ollama and OpenAI-compatible models, plus routing, database, validation, auth and MCP in one package.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "BrowserlessAPI",
8
+ "homepage": "https://github.com/BrowserlessAPI/Inferlo#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/BrowserlessAPI/Inferlo.git"
12
+ },
13
+ "bugs": "https://github.com/BrowserlessAPI/Inferlo/issues",
14
+ "keywords": [
15
+ "typescript",
16
+ "backend",
17
+ "framework",
18
+ "api",
19
+ "rest",
20
+ "orm",
21
+ "validation",
22
+ "ai",
23
+ "llm",
24
+ "mcp",
25
+ "ai-agents",
26
+ "bun",
27
+ "deno",
28
+ "edge"
29
+ ],
30
+ "engines": {
31
+ "node": ">=22.18"
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "./postgres": {
39
+ "types": "./dist/db/postgres.d.ts",
40
+ "default": "./dist/db/postgres.js"
41
+ },
42
+ "./testing": {
43
+ "types": "./dist/testing.d.ts",
44
+ "default": "./dist/testing.js"
45
+ }
46
+ },
47
+ "bin": {
48
+ "inferlo": "./dist/cli/main.js"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "templates",
53
+ "README.md",
54
+ "LICENSE",
55
+ "llms.txt"
56
+ ],
57
+ "scripts": {
58
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli/main.js",
59
+ "prepare": "npm run build",
60
+ "typecheck": "tsc --noEmit && tsc --noEmit -p examples/helpdesk",
61
+ "test": "node --test --test-reporter=spec \"test/**/*.test.ts\" \"examples/*/test/*.test.ts\"",
62
+ "test:bun": "bun test test/",
63
+ "llms": "node scripts/build-llms.ts",
64
+ "check": "npm run typecheck && npm test && node scripts/build-llms.ts --check",
65
+ "bench": "node bench/run.ts"
66
+ },
67
+ "peerDependencies": {
68
+ "pg": ">=8"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "pg": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "devDependencies": {
76
+ "@types/node": "^22.18.0",
77
+ "@types/pg": "^8.15.0",
78
+ "pg": "^8.16.0",
79
+ "typescript": "^5.9.3"
80
+ },
81
+ "publishConfig": {
82
+ "access": "public"
83
+ }
84
+ }
@@ -0,0 +1,53 @@
1
+ # AGENTS.md
2
+
3
+ This is a [Inferlo](https://github.com/BrowserlessAPI/Inferlo) backend. Inferlo is a TypeScript backend framework designed so AI coding agents get things right the first time. Follow these rules.
4
+
5
+ ## Before you change anything
6
+
7
+ ```bash
8
+ npx inferlo describe --json # every model, field, route, auth rule and AI alias
9
+ ```
10
+
11
+ Read that instead of searching the codebase. After every change run:
12
+
13
+ ```bash
14
+ npx inferlo check --json # typecheck + load the app + tests, machine-readable errors
15
+ ```
16
+
17
+ Do not finish until `inferlo check` passes.
18
+
19
+ ## Layout
20
+
21
+ | File | What goes there |
22
+ |---|---|
23
+ | `src/models.ts` | Every model (`model("table_name", { ... })`) and the `models` array |
24
+ | `src/routes/<feature>.ts` | Routes for one feature, each one an exported `const` |
25
+ | `src/app.ts` | `createApp({ models, routes, db, auth, ai })`. Import route modules with `import * as x` |
26
+ | `src/server.ts` | Starts the server. Keep it small |
27
+ | `test/<feature>.test.ts` | Tests using `app.request(path, { method, json, as: user })` |
28
+
29
+ ## Rules
30
+
31
+ 1. **One way to do each thing.** Routes: `route.get/post/put/patch/delete(path, options).handle(fn)`. Validation: `t.*` schemas. Database: `db.<table>.<method>`. AI: `ai.generate` / `ai.stream` / `ai.embed`.
32
+ 2. **Routes are private by default.** A route without `auth` needs a signed-in user. Use `auth: "public"` only for routes anyone may call, and `auth: "<role>"` for role checks.
33
+ 3. **Validate every input with a schema.** `body`, `query` and `params` options. Reuse model schemas: `Ticket.pick("title", "body")`, `Ticket.omit("ownerId")`, `.partial()` for PATCH.
34
+ 4. **Check ownership.** Fetch with `db.x.get(id)` (throws 404) and compare `ownerId` to `user.id`; throw `notFound()` when it does not match, so other users' ids are not revealed.
35
+ 5. **Never write raw SQL with string concatenation.** Use the table methods. If you really need SQL, use `db.$sql(text, params)` with `$1` placeholders.
36
+ 6. **Model ids, timestamps:** every model already has `id`, `createdAt`, `updatedAt`. Never define them or send them as input.
37
+ 7. **New field?** Add it to the model in `src/models.ts`, then run `npx inferlo db push` in development, or `npx inferlo db migrate <name>` to write a migration file for production (applied with `npx inferlo db migrate --apply`). Use `.optional()` or `.default()` for new fields on existing tables. Never edit `migrations/snapshot.json`, and never edit a migration file that has been applied (`--apply` warns with IL4018 and does not run the change): put changes in a new migration. Removing a model or field needs `npx inferlo db migrate <name> --allow-drop` (it deletes that data); never use it for a rename. Changing a field's default or making it required or optional needs `--allow-change`; give a newly required field a `.default()` so existing NULL rows get a value.
38
+ 8. **New resource?** `npx inferlo add resource <plural_name>` generates the model and CRUD routes. Then edit them.
39
+ 9. **AI calls** use aliases from `src/app.ts` (`model: "fast"`), never hard-coded provider model ids in routes. Use `output: schema` for structured data and `for: user.id` so usage is tracked per user.
40
+ 10. **Errors:** throw `notFound()`, `forbidden()`, `badRequest()` or `httpError(status, message)`. Every Inferlo error has a code (KRxxxx), a fix and a docs link: read the fix before changing code.
41
+ 11. **Imports:** relative imports end in `.ts` (`import { Note } from "../models.ts"`).
42
+ 12. **Expose to AI agents:** add `tool: true` to a route to make it an MCP tool at `/mcp`. Same auth applies.
43
+
44
+ ## Testing
45
+
46
+ ```ts
47
+ const res = await app.request("/notes", { method: "POST", json: { title: "Hi" }, as: user });
48
+ const note = await readJson(res, 201); // from "@browserlessapi/inferlo/testing"
49
+ ```
50
+
51
+ Use `memory()` for the database and `mockProvider()` for AI in tests.
52
+
53
+ Full docs: https://github.com/BrowserlessAPI/Inferlo/tree/main/docs
@@ -0,0 +1 @@
1
+ Read AGENTS.md before making changes. Run `npx inferlo describe --json` to see the app, and `npx inferlo check --json` after every change.
@@ -0,0 +1,14 @@
1
+ # {{name}}
2
+
3
+ Built with [Inferlo](https://github.com/BrowserlessAPI/Inferlo).
4
+
5
+ ```bash
6
+ npm install
7
+ npm run dev # starts on http://localhost:3000 and prints a dev API key
8
+ npm run check # typecheck + tests
9
+ npx inferlo describe # routes, models and auth at a glance
10
+ ```
11
+
12
+ Set `DATABASE_URL` in `.env` to use PostgreSQL, then run `npx inferlo db push`.
13
+
14
+ AI coding agents: read `AGENTS.md`.
@@ -0,0 +1,5 @@
1
+ # Leave DATABASE_URL empty to use the in-memory database (data resets on restart).
2
+ DATABASE_URL=
3
+ PORT=3000
4
+ ANTHROPIC_API_KEY=
5
+ OPENAI_API_KEY=
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ .env
3
+ dist
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "{{name}}",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "inferlo": {
7
+ "entry": "src/app.ts",
8
+ "server": "src/server.ts"
9
+ },
10
+ "scripts": {
11
+ "dev": "inferlo dev",
12
+ "start": "node src/server.ts",
13
+ "check": "inferlo check",
14
+ "describe": "inferlo describe",
15
+ "db:push": "inferlo db push",
16
+ "db:migrate": "inferlo db migrate",
17
+ "test": "node --test \"test/**/*.test.ts\"",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "engines": {
21
+ "node": ">=22.18"
22
+ },
23
+ "dependencies": {
24
+ "@browserlessapi/inferlo": "{{inferloDependency}}",
25
+ "pg": "^8.16.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.18.0",
29
+ "typescript": "^5.9.3"
30
+ }
31
+ }
@@ -0,0 +1,38 @@
1
+ import { anthropic, bearer, createApp, env, hashToken, memory, openai } from "@browserlessapi/inferlo";
2
+ import { postgres } from "@browserlessapi/inferlo/postgres";
3
+ import { models } from "./models.ts";
4
+ import * as health from "./routes/health.ts";
5
+ import * as notes from "./routes/notes.ts";
6
+
7
+ const databaseUrl = env("DATABASE_URL", { optional: true });
8
+
9
+ export const app = createApp({
10
+ name: "{{name}}",
11
+ version: "0.1.0",
12
+ models,
13
+ // inferlo:routes
14
+ routes: [health, notes],
15
+ db: databaseUrl ? postgres({ url: databaseUrl }) : memory(),
16
+
17
+ // Requests send "Authorization: Bearer <api key>". Keys are stored as SHA-256 hashes.
18
+ auth: bearer(async (token, { db }) => {
19
+ const key = await db.api_keys.findOne({ hash: await hashToken(token) });
20
+ if (!key) return null;
21
+ const user = await db.users.find(key.userId);
22
+ return user ? { id: user.id, email: user.email, name: user.name, roles: [user.role] } : null;
23
+ }),
24
+
25
+ ai: {
26
+ providers: {
27
+ anthropic: anthropic({ apiKey: env("ANTHROPIC_API_KEY", { default: "" }) }),
28
+ openai: openai({ apiKey: env("OPENAI_API_KEY", { default: "" }) }),
29
+ },
30
+ // Aliases keep model ids in one place. A list means: try in order.
31
+ models: {
32
+ fast: ["anthropic:claude-haiku-4-5", "openai:gpt-5-mini"],
33
+ smart: ["anthropic:claude-sonnet-4-5", "openai:gpt-5"],
34
+ },
35
+ },
36
+ });
37
+
38
+ export default app;
@@ -0,0 +1,31 @@
1
+ import { model, t } from "@browserlessapi/inferlo";
2
+
3
+ export const User = model("users", {
4
+ email: t.email().unique(),
5
+ name: t.string().max(80),
6
+ role: t.enum(["member", "admin"]).default("member"),
7
+ });
8
+
9
+ export const ApiKey = model("api_keys", {
10
+ userId: t.ref("users", { onDelete: "cascade" }),
11
+ hash: t.string().max(64).unique(),
12
+ label: t.string().max(80).default("default"),
13
+ });
14
+
15
+ export const Note = model("notes", {
16
+ title: t.string().min(1).max(200),
17
+ body: t.text().default(""),
18
+ ownerId: t.ref("users", { onDelete: "cascade" }),
19
+ });
20
+
21
+ // inferlo:models
22
+ export const models = [User, ApiKey, Note] as const;
23
+
24
+ export type AppUser = { id: string; email: string; name: string; roles: string[] };
25
+
26
+ declare module "@browserlessapi/inferlo" {
27
+ interface Register {
28
+ models: typeof models;
29
+ user: AppUser;
30
+ }
31
+ }
@@ -0,0 +1,3 @@
1
+ import { route } from "@browserlessapi/inferlo";
2
+
3
+ export const health = route.get("/health", { auth: "public", summary: "Health check" }).handle(() => ({ ok: true }));
@@ -0,0 +1,56 @@
1
+ import { notFound, route, t } from "@browserlessapi/inferlo";
2
+ import { Note } from "../models.ts";
3
+
4
+ const IdParams = t.object({ id: t.uuid() });
5
+
6
+ export const listNotes = route
7
+ .get("/notes", {
8
+ summary: "List my notes",
9
+ query: t.object({ limit: t.int().min(1).max(100).default(20), offset: t.int().min(0).default(0) }),
10
+ tool: true,
11
+ })
12
+ .handle(({ query, user, db }) =>
13
+ db.notes.findMany({ where: { ownerId: user.id }, orderBy: { createdAt: "desc" }, limit: query.limit, offset: query.offset }),
14
+ );
15
+
16
+ export const getNote = route
17
+ .get("/notes/:id", { params: IdParams, summary: "Get one note", tool: true })
18
+ .handle(async ({ params, user, db }) => {
19
+ const note = await db.notes.get(params.id);
20
+ if (note.ownerId !== user.id) throw notFound();
21
+ return note;
22
+ });
23
+
24
+ export const createNote = route
25
+ .post("/notes", { body: Note.pick("title", "body"), summary: "Create a note", tool: true })
26
+ .handle(({ body, user, db, status }) => {
27
+ status(201);
28
+ return db.notes.insert({ ...body, ownerId: user.id });
29
+ });
30
+
31
+ export const updateNote = route
32
+ .patch("/notes/:id", { params: IdParams, body: Note.pick("title", "body").partial(), summary: "Update a note" })
33
+ .handle(async ({ params, body, user, db }) => {
34
+ const note = await db.notes.get(params.id);
35
+ if (note.ownerId !== user.id) throw notFound();
36
+ return db.notes.update(note.id, body);
37
+ });
38
+
39
+ export const deleteNote = route
40
+ .delete("/notes/:id", { params: IdParams, summary: "Delete a note" })
41
+ .handle(async ({ params, user, db }) => {
42
+ const note = await db.notes.get(params.id);
43
+ if (note.ownerId !== user.id) throw notFound();
44
+ await db.notes.delete(note.id);
45
+ });
46
+
47
+ const Summary = t.object({ summary: t.string().max(300).describe("One or two sentences") });
48
+
49
+ export const summarizeNote = route
50
+ .post("/notes/:id/summary", { params: IdParams, summary: "Summarize a note with AI" })
51
+ .handle(async ({ params, user, db, ai }) => {
52
+ const note = await db.notes.get(params.id);
53
+ if (note.ownerId !== user.id) throw notFound();
54
+ const { object } = await ai.generate({ model: "fast", prompt: `Summarize:\n\n${note.title}\n\n${note.body}`, output: Summary, for: user.id });
55
+ return object;
56
+ });
@@ -0,0 +1,16 @@
1
+ import { createToken, env, hashToken } from "@browserlessapi/inferlo";
2
+ import { app } from "./app.ts";
3
+
4
+ await app.db.$sync();
5
+
6
+ // In-memory database: create a demo user and print an API key so you can try the API.
7
+ if (app.db.$adapter.dialect === "memory") {
8
+ const user = await app.db.users.insert({ email: "dev@example.com", name: "Developer", role: "admin" });
9
+ const token = createToken("dev");
10
+ await app.db.api_keys.insert({ userId: user.id, hash: await hashToken(token) });
11
+ console.log(`Dev API key: ${token}`);
12
+ console.log(`Try: curl -H "Authorization: Bearer ${token}" http://localhost:${env("PORT", { default: "3000" })}/notes`);
13
+ }
14
+
15
+ const server = await app.listen(env.int("PORT", { default: 3000 }));
16
+ console.log(`{{name}} running on http://localhost:${server.port}`);
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { createApp, memory, mockProvider } from "@browserlessapi/inferlo";
4
+ import { readJson } from "@browserlessapi/inferlo/testing";
5
+ import { models } from "../src/models.ts";
6
+ import * as notes from "../src/routes/notes.ts";
7
+
8
+ function makeApp() {
9
+ return createApp({
10
+ models,
11
+ routes: [notes],
12
+ db: memory(),
13
+ auth: { resolve: () => null },
14
+ ai: { providers: { mock: mockProvider({ respond: () => '{"summary":"A short note."}' }) }, models: { fast: "mock:test" } },
15
+ });
16
+ }
17
+
18
+ test("create, list, update and delete notes", async () => {
19
+ const app = makeApp();
20
+ const row = await app.db.users.insert({ email: "a@example.com", name: "A" });
21
+ const user = { id: row.id, email: row.email, name: row.name, roles: [] };
22
+
23
+ const note = await readJson(await app.request("/notes", { method: "POST", json: { title: "First" }, as: user }), 201);
24
+ assert.equal(note.title, "First");
25
+
26
+ const list = await readJson(await app.request("/notes", { as: user }), 200);
27
+ assert.equal(list.length, 1);
28
+
29
+ const updated = await readJson(await app.request(`/notes/${note.id}`, { method: "PATCH", json: { body: "Hello" }, as: user }), 200);
30
+ assert.equal(updated.body, "Hello");
31
+
32
+ const summary = await readJson(await app.request(`/notes/${note.id}/summary`, { method: "POST", as: user }), 200);
33
+ assert.equal(summary.summary, "A short note.");
34
+
35
+ assert.equal((await app.request(`/notes/${note.id}`, { method: "DELETE", as: user })).status, 204);
36
+ });
37
+
38
+ test("other users cannot read my notes", async () => {
39
+ const app = makeApp();
40
+ const a = await app.db.users.insert({ email: "a@example.com", name: "A" });
41
+ const b = await app.db.users.insert({ email: "b@example.com", name: "B" });
42
+ const note = await app.db.notes.insert({ title: "Private", ownerId: a.id });
43
+ const res = await app.request(`/notes/${note.id}`, { as: { id: b.id, email: b.email, name: b.name, roles: [] } });
44
+ assert.equal(res.status, 404);
45
+ });
46
+
47
+ test("requests without a key are rejected", async () => {
48
+ const res = await makeApp().request("/notes");
49
+ assert.equal(res.status, 401);
50
+ });
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "verbatimModuleSyntax": true,
10
+ "erasableSyntaxOnly": true,
11
+ "allowImportingTsExtensions": true,
12
+ "noEmit": true,
13
+ "skipLibCheck": true,
14
+ "types": ["node"]
15
+ },
16
+ "include": ["src", "test"]
17
+ }