Writing Modular APIs
Arbor does not enforce a project structure. It only cares about branches, routes and actions at runtime. How you arrange files is your choice.
This guide describes one structure that works well for larger applications:
- predictable file locations
- URLs mapped to directories
- clear separation of server, app and view concerns
Use it as a starting point and adapt it to your needs.
Top-level layout
A simple layout:
src/
- server.ts
- app.ts
- app/
- - dashboard/
- - - action.ts
- - api/
- - - branch.ts
- - - users/
- - - - action.ts
- - - - branch.ts
- - - - create/
- - - - - action.ts
/src/server.tscreates the HTTP server./src/app.tsbuilds the Arbor app (branches, routes, error handlers)./src/app/mirrors your URLs.
This keeps the entry points obvious and lets you more easily navigate the filesystem.
Entry points
// src/server.ts
import { createServer } from "node:http";
import app from "./app";
createServer(app).listen(4000, () => {
console.log("Listening on http://localhost:4000");
});
// src/app.ts
import { createApp } from "@kequtech/arbor";
import branchApi from "./app/api/branch.ts";
import actionDashboard from "./app/dashboard/action.ts";
export default createApp({
routes: [
{
method: "GET",
url: "/dashboard",
actions: [
actionDashboard,
],
},
],
branches: [
branchApi,
],
});
Mapping URLs to directories
Under /src/app, each directory represents a part of the URL:
/dashboard→/src/app/dashboard/api/users→/src/app/api/users/api/users/create→/src/app/api/users/create
Inside each directory:
action.tsdefines the action.branch.tsdefines a branch for that subtree when needed.- Optional view-related files (
page.mustache,page.client.ts) can live in the same directory.
Example:
src/app/
- dashboard/
- - action.ts
- - page.mustache
- - page.client.ts
Simple action example
// src/app/dashboard/action.ts
import { createAction } from "@kequtech/arbor";
export default createAction(() => {
return "Dashboard";
});
In this case app.ts imports actionDashboard and registers it.
Branch files
For sections of the API that share behavior, define a branch.ts in the directory that represents the prefix. Example API tree:
src/app/api/
- branch.ts
- users/
- - action.ts
- - branch.ts
- - create/
- - - action.ts
/api branch:
// src/app/api/branch.ts
import { createBranch } from "@kequtech/arbor";
import branchUsers from "./users/branch.ts";
export default createBranch({
url: "/api",
branches: [
branchUsers,
],
});
/api/users branch:
// src/app/api/users/branch.ts
import { createBranch } from "@kequtech/arbor";
import actionUsers from "./action.ts";
import actionUsersCreate from "./create/action.ts";
export default createBranch({
url: "/users",
routes: [
{
method: "GET",
url: "/",
actions: [
actionUsers,
],
},
{
method: "POST",
url: "/",
actions: [
actionUsersCreate,
],
},
],
});
/api/users action:
// src/app/api/users/action.ts
import { createAction } from "@kequtech/arbor";
export default createAction(() => {
return [{ id: 1, name: "User" }];
});
/api/users/create action:
// src/app/api/users/create/action.ts
import { createAction } from "@kequtech/arbor";
export default createAction(async ({ getBody }) => {
const body = await getBody({
trim: true,
required: ["name"],
});
return { created: body.name };
});
This pattern keeps each action small and easy to locate.
Co-locating views and client code
If a route has a server-rendered page or client-side behavior, keep those files in the same directory:
src/app/dashboard/
- action.ts // action logic
- page.mustache // template for HTML
- page.client.ts // client-side behavior for this page
Your action might use a renderer that knows how to load page.mustache from this directory.
// src/app/dashboard/action.ts
import { createAction } from "@kequtech/arbor";
import type { PayloadHtml } from "#lib/html-renderer.ts";
export default createAction(({ context }): PayloadHtml => {
context.view = "dashboard/page.mustache";
return { title: "Dashboard" };
});
A renderer can then use context.view and the payload to render HTML.
// src/lib/html-renderer.ts
import { createRenderer } from "@kequtech/arbor";
export interface PayloadHtml {
title: string;
}
export const rendererHtml = createRenderer({
contentType: 'text/html',
action: async (payload, { context }) => {
const { title } = payload as PayloadHtml;
const view = context.view as string | undefined;
// etc.
},
});
Shared modules
For cross-cutting concerns, use a src/lib or src/shared directory:
src/
- app.ts
- server.ts
- app/
- - ...
- lib/
- - auth.ts
- - db.ts
- - validators.ts
Examples:
lib/auth.tsforactionAuthRequiredand role checkslib/db.tsfor database access helperslib/validators.tsfor reusable input validators
Shared modules often define small context interfaces. Actions that depend on these properties can use a context hint:
// src/lib/auth.ts
export interface ContextAuth {
user?: { id: string };
}
export const actionAuth = createAction(({ context, cookies }) => {
const token = cookies.get("auth");
const user = getUserFromToken(token);
if (!user) throw Ex.unauthorized("Invalid auth token");
context.user = user;
});
// src/app/dashboard/action.ts
import type { ContextAuth } from "#lib/auth.ts";
export default createAction<ContextAuth>(({ context }) => {
context.user; // { id: string }
});
Context hints make cross-module assumptions visible without requiring a global context type. For more guidance on typing patterns, see Types.
Testing structure
Can also choose to add tests to the file layout:
src/
- app/
- - api/
- - - users/
- - - - action.ts
- - - - action.test.ts
Co-locating tests with actions keeps everything close to the code that it verifies.
Summary
A practical Arbor layout:
/src/server.tsfor the HTTP server/src/app.tsfor building the Arbor app/src/app/**where directories mirror URLsaction.tsandbranch.tsfiles inside those directories- optional
page.mustache,page.client.ts, andaction.test.tsco-located with routes - shared logic in
/src/libor/src/shared
This structure is not required by Arbor, but it scales well and makes it easy to locate behavior from looking at the URL.