Adding an internal API route in Trilium
The first decision is which routes/api/ directory. There are TWO, and the common assumption that all routes live server-side in apps/server/src/routes/api/ is wrong — that's the minority case. ~140 of the routes live in the core one:
| Directory | Wired by | Runs in | Testable by |
|---|---|---|---|
packages/trilium-core/src/routes/api/* (default, ~140 routes) |
buildSharedApiRoutes in packages/trilium-core/src/routes/index.ts:84 |
server + desktop + standalone WASM | CoreApiTester (cross-runtime) and supertest |
apps/server/src/routes/api/* (server-only) |
register() in apps/server/src/routes/routes.ts:47 |
server + desktop only | supertest only |
A route you add only in routes.ts is invisible to the standalone build and cannot be driven by CoreApiTester. A core handler that imports node:* or touches process.env breaks the WASM build. Pick the wrong side and you either lose standalone support or break it.
Footgun checklist (read before you start)
- Default to core. Note/branch/attribute/tree/search/revision/import logic is browser-safe → put it in
packages/trilium-core/src/routes/api/and register inindex.ts'sbuildSharedApiRoutes. Only Node-only or genuinely server-scoped routes go inroutes.ts. (Step 1.) - Async handler ⇒
asyncApiRoute, neverapiRoute.apiRouterunssql.transactional(cb)synchronously (route_api.ts:100) — hand it an async function and it commits an empty transaction immediately; your awaited writes land outside any transaction. (Step 2.) - Return conventions are implicit. object/array/string →
200;undefined/nothing →204;[integerStatus, body]tuple → that status. ThrowValidationError/NotFoundErrorfor 400/404. (Step 3.) convertEntitiesToPojoonly unwraps narrow shapes. ABNotenested underresult.notes[],result.parentNote,result.attributes[]serializes raw/broken — call.getPojo()yourself or shape undernote/branch. (Step 4.)- Core handlers must be browser-safe: no
process.env(usegetPlatform().getEnv()), nonode:path/import path(useservices/utils/path.tsextname/basename), no Node built-ins. - Registration order matters cross-runtime — register a literal path before a same-method
:paramcatch-all on the same prefix. (Step 5.) - Don't write the test by hand — core route ⇒
CoreApiTester; Express transport ⇒ supertest. Hand off to the writing-unit-tests skill. (Step 6.)
Step 1 — Decide: core-shared vs server-only
| Put the handler in… | When |
|---|---|
core packages/trilium-core/src/routes/api/<m>.ts + register in index.ts buildSharedApiRoutes |
logic uses only browser-safe deps (becca, getSql(), core services, platform providers) AND the feature should work in standalone. Default for note/branch/attribute/tree/search/etc. |
server-only apps/server/src/routes/api/<m>.ts + register in routes.ts register() |
needs Node-only deps not behind a platform provider (multer fs paths, OCR/LLM SDKs, metrics, sender, electron), or is genuinely server-scoped (etapi-tokens, totp, recovery codes) |
The server-only set is small and specific — clipper, database, llm_chat, ocr, metrics, sender, totp, fonts, link_embed, recovery_codes, system_info, etapi_tokens, plus a few files extras. See the register() body in routes.ts (the shared set is wired by buildSharedApiRoutes at routes.ts:97). Everything else is core.
Browser-safe rule for core handlers: no process.env, no node:path/import path, no Node built-ins — they run in the sqlite-wasm standalone build. (script.ts:75,86 reads process.env.TRILIUM_SAFE_MODE directly — that is a latent violation, not a pattern to copy.)
Step 2 — Pick the wrapper
All four wrappers live in apps/server/src/routes/route_api.ts (apiRoute:69, asyncApiRoute:73, route:77, asyncRoute:81). Their names arrive via the buildSharedApiRoutes context object, so registering once in index.ts auto-wires all three call sites (server routes.ts:97, standalone browser_routes.ts:281, api_tester.ts:264).
A fifth name, asyncRouteWithoutTransaction, is also in the context object (index.ts:84) — use it for a long-running async handler that must not hold a transaction open.
| Wrapper | Handler shape | Transaction | Auth + CSRF | Result handling |
|---|---|---|---|---|
apiRoute(method, path, h) |
synchronous (req) => value |
sql.transactional (sync) |
checkApiAuth + csrf, auto |
apiResultHandler |
asyncApiRoute(method, path, h) |
async (req) => Promise |
async (no sync wrap) | checkApiAuth + csrf, auto |
apiResultHandler |
route(method, path, [mw], h, resultHandler?) |
sync; writes res directly OR custom [mw] (image/file download, setup, sync) |
sync | explicit [mw] |
optional |
asyncRoute(method, path, [mw], h, resultHandler?) |
async + explicit [mw] (import/export, multipart) |
async | explicit [mw] |
optional |
The sync-transactional trap (route_api.ts:100):
return transactional ? sql.transactional(cb) : cb();
apiRoute → route(...true) → internalRoute(..., transactional=true); asyncApiRoute → asyncRoute(...false) → transactional=false. So apiRoute calls sql.transactional(cb) synchronously. If cb is async, sql.transactional sees a returned Promise, commits the (empty) transaction, and your awaited writes run after the commit, outside any transaction. The standalone adapter documents this exact trap in browser_routes.ts:127-133 ("would commit an empty transaction immediately when passed an async callback"). Async handler ⇒ asyncApiRoute. Always.
Auth/CSRF: apiRoute/asyncApiRoute attach auth.checkApiAuth + csrfMiddleware automatically (route_api.ts:70,74). Mutating endpoints therefore carry CSRF for free. Routes called from outside a browser session (setup, sync, sender, image/file download) use bare route()/asyncRoute() with an explicit [middleware] array (often checkApiAuthOrElectron or checkApiAuth) — see the route(...)/asyncRoute(...) registrations in index.ts and routes.ts.
Step 3 — Return value conventions
From apiResultHandler (route_api.ts:29-47), mirrored by the standalone apiResultHandler (browser_routes.ts:227) and CoreApiTester.formatApiResult (api_tester.ts:100):
| Your handler returns… | HTTP status | Real example |
|---|---|---|
| object / array / string | 200 |
most handlers |
undefined / nothing |
204 |
branches.ts setPrefix, setExpanded |
[integer, body] (first elem is an int) |
that status code | branches.ts:42,90 [200, …]; import.ts:67,71,126 [500, msg]; revisions.ts:123 [400, "Description must be a string."] |
throw new ValidationError(...) / NotFoundError(...) |
400 / 404 |
mapped by handleException (route_api.ts:130); CoreApiTester maps HttpError→status at api_tester.ts:335 |
Direct-response handlers (image/download) write to res themselves and use route(), not apiRoute() — see the direct-response registrations in index.ts.
Step 4 — Entity serialization (convertEntitiesToPojo)
index.ts:365-390 only unwraps AbstractBeccaEntity instances in five shapes:
- the top-level result is an entity (
:366) - the top-level result is an array of entities (
:368-372) result.note(:375-376)result.branch(:379-380)- (recursively)
result.executionResult, fromrunOnBackend()(:384-387)
Anything else serializes raw. A BNote under result.notes[], result.parentNote, result.attributes[], result.attachment, or any other key is not unwrapped — JSON.stringify then ships the live entity (lazy getters, becca back-references) and the response is wrong or throws. Fix: call .getPojo() yourself when building the response, or place the entity under a recognized note/branch key. createTextNote relies on this: the create handler returns { note, branch } and both get unwrapped.
Step 5 — Registration order
Both Express and CoreApiTester match in registration order, first match wins — the tester iterates this.routes and returns on the first match (api_tester.ts:301). Register a literal path before a same-method :param catch-all on the same prefix. Precedent (etapi side): routes.ts:190 — "Register revisions routes BEFORE notes routes so /etapi/notes/history is matched before /etapi/notes/:noteId". Current core /api/notes/... literals avoid collisions only because they differ by HTTP method (e.g. POST /api/notes/erase-deleted-notes-now vs GET /api/notes/:noteId) — if you add a literal that shares a method with an existing :param route on the same prefix, put the literal first.
Step 6 — Test it (handoff, don't reinvent)
Don't hand-roll a route test. Use the existing harness:
| What you added | Test with | Where |
|---|---|---|
| core handler | CoreApiTester.build() (packages/trilium-core/src/test/api_tester.ts) |
co-located packages/trilium-core/src/routes/api/<m>.spec.ts — runs under both server and standalone suites |
| Express transport (CSRF/auth wiring) | supertest agent + bootLoggedInApp() |
apps/server/src/routes/api/core_routes_http.spec.ts |
| server-only route | supertest agent | co-located apps/server/src/routes/api/<m>.spec.ts |
Minimal core spec (full patterns + fixtures live in the writing-unit-tests skill, server-and-core.md Pattern 0/1 — reference it, don't restate):
import { beforeAll, describe, expect, it } from "vitest";
import { createTextNote } from "../../test/api_fixtures";
import { CoreApiTester } from "../../test/api_tester";
let api: CoreApiTester;
describe("Widget API (core)", () => {
beforeAll(() => { api = CoreApiTester.build(); });
it("returns the widget", async () => {
const note = await createTextNote(api, { title: "Source" });
const res = await api.get(`/api/widgets/${note.noteId}`);
expect(res.status).toBe(200);
});
});
createTextNote(api, {...}) → { noteId, branchId } lives in packages/trilium-core/src/test/api_fixtures.ts. A core spec runs cross-runtime — run both suites before calling it done (pnpm --filter server exec vitest run <spec> AND pnpm --filter standalone exec vitest run <spec>); the providers differ. See writing-unit-tests for the cross-runtime traps and the Windows/sandbox vitest invocation.
Quick recipe (core route, the common case)
- Write
getX/putXinpackages/trilium-core/src/routes/api/<m>.tsas a sync(req) => value(orasyncif it awaits) handler; default-export the object of handlers. - Add the registration line to
buildSharedApiRoutesinindex.ts—apiRoute(GET, "/api/...", <m>Route.getX)for sync,asyncApiRoute(...)for async. That single line wires server + desktop + standalone +CoreApiTester. - Return a value (Step 3); keep it browser-safe (Step 1); unwrap entities under
note/branchor via.getPojo()(Step 4); mind ordering (Step 5). - Add a co-located
<m>.spec.tswithCoreApiTesterand run both suites (Step 6).
Reference map
| File | What it covers |
|---|---|
| references/wrappers-and-conventions.md | Full wrapper matrix (handler shape, sync vs async transaction, middleware, result handler), the return-value table with real call sites, the convertEntitiesToPojo unwrap table, and how the three buildSharedApiRoutes call sites (Express route_api.ts, standalone browser_routes.ts, CoreApiTester api_tester.ts) each implement the same context. |
Related skills: writing-unit-tests (server-and-core.md Pattern 0 = CoreApiTester, Pattern 1 = supertest transport) for testing; analyzing-coverage for chasing the new handler's coverage.