Skip to content

Latest commit

 

History

History
104 lines (78 loc) · 7.18 KB

File metadata and controls

104 lines (78 loc) · 7.18 KB

Backend Architecture

Loosely inspired by the Controller–Service–Repository pattern with dependency injection. The backend is organized as a stack of layers where each layer only depends on the layers beneath it, and PuterServer (src/backend/server.ts) instantiates each layer in order and hands the instances down to the next.

Layers

block-beta
    columns 1
    REQ["HTTP request"]
    CTRL["Controllers — route handlers, gates, I/O shaping"]
    DRV["Drivers (optional) — RPC handlers on /drivers/*"]
    SVC["Services — business logic, no auth"]
    STR["Stores — persistence / domain shapes"]
    CLI["Clients — sql, redis, s3, dynamo, email, …"]
    CFG["Config — IConfig"]

    REQ --> CTRL
    CTRL --> DRV
    DRV --> SVC
    SVC --> STR
    STR --> CLI
    CLI --> CFG

    style REQ fill:#0ea5e9,stroke:#0369a1,color:#fff
    style CTRL fill:#1d4ed8,stroke:#1e3a8a,color:#fff
    style DRV fill:#2563eb,stroke:#1e40af,color:#fff
    style SVC fill:#4f46e5,stroke:#3730a3,color:#fff
    style STR fill:#7c3aed,stroke:#5b21b6,color:#fff
    style CLI fill:#9333ea,stroke:#6b21a8,color:#fff
    style CFG fill:#334155,stroke:#1e293b,color:#fff
Loading

Each layer only depends on the layers beneath it, and every dependency is injected through the constructor by PuterServer. Extensions sit alongside this stack and can register into any layer — see Extensions below.

Layer Lives in Responsibility
Controllers src/backend/controllers/ Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers — see RouteOptions), call into services, format responses.
Drivers src/backend/drivers/ Optional. RPC-style handlers exposed over the /drivers/* surface (puter-kvstore, puter-chat-completion, …). A driver is a thin shell that validates RPC inputs and calls into services/stores; controllers can hold a typed reference to drivers when they need the same logic over HTTP.
Services src/backend/services/ Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves.
Stores src/backend/stores/ Persistence and storage logic. Wraps clients with the domain shape services consume (rows, entities, KV namespaces).
Clients src/backend/clients/ Adapters for external/internal services (sql, redis, s3, dynamodb, email, event bus, …). Knows protocols, not domain concepts.
Config config.*.jsonIConfig The flat, typed config object every layer receives at construction.

Each layer receives the layers beneath it through its constructor, so dependencies are explicit and traceable from PuterServer. A controller does not reach into a client directly; if it needs one, the right move is usually a service.

Entry point: PuterServer

PuterServer is the bootstrap. It:

  1. Loads any configured extension directories (config.extensions) so extensions can register before instantiation begins.
  2. Instantiates each layer in order — clients → stores → services → drivers → controllers — merging in anything extensions have registered for that layer.
  3. Wires global middleware, mounts controller routes through PuterRouter (which translates RouteOptions into the gate/parser middleware chain), and mounts extension routes through the same materializer.
  4. Fires onServerStart hooks across every layer once HTTP is listening, and onServerPrepareShutdown / onServerShutdown on the way down.

Context (ALS)

We use Context — backed by AsyncLocalStorage — to carry per-request state without threading it through every function signature. It is used sparingly, mostly for actor and req. The request-context middleware opens a scope per request after the auth probe runs; anything inside a request handler can call Context.get('actor') / Context.get('req') instead of plumbing it as an argument.

Prefer explicit arguments. Reach for Context only when the value is truly request-scoped and would otherwise need to thread through many layers.

Extensions

Extensions live alongside core (packages/puter/extensions/) and parallel the layered stack. They are meant for non-crucial parts of the system — things Puter still works without if removed.

  • Good extensions: thumbnails, serverInfo, devWatcher — opt-in features cleanly bolted on.
  • Should probably be core: metering, appTelemetry — clients now expect these to be present, so the "extension" framing is misleading.
  • Shouldn't have been an extension: whoami — it's load-bearing for every authenticated client. Keep this one in mind as a cautionary example when deciding whether something belongs in an extension.

Extension API

The extension global (src/backend/extensions.ts) exposes:

  • Layer registration for first-class additions:
    • extension.registerClient(name, ClientClass)
    • extension.registerStore(name, StoreClass)
    • extension.registerService(name, ServiceClass)
    • extension.registerDriver(name, DriverClass)
    • extension.registerController(name, ControllerClass)
  • Lightweight wrappers for the common case where a full class isn't worth it:
    • extension.on(event, handler) — subscribe to event-bus events.
    • extension.get(path, opts?, handler) / .post / .put / .delete / .patch / .head / .options / .all / .use — register routes. The opts shape is the same RouteOptions controllers use, so subdomain, requireAuth, adminOnly, body parsers, etc. all work identically.
  • Cross-layer access: extension.import('client' | 'store' | 'service' | 'controller' | 'driver') returns a lazy proxy to instantiated objects, and extension.config exposes the live config.
import { extension } from '@heyputer/backend/src/extensions';

const services = extension.import('service');

extension.get('/healthcheck/deep', { subdomain: 'api', adminOnly: true }, async (_req, res) => {
    res.json({ ok: await services.health.runDeepCheck() });
});

extension.on('user.signup', (_key, data) => {
    console.log('new user', data.user.username);
});

Conventions

  • TypeScript preferred in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file.
  • camelCase for variable/function names; PascalCase for classes and for files that contain a class (AuthService.ts, KVStoreDriver.ts).
  • Deduplicate. If two services need the same logic, lift it into a util/helper rather than calling sideways across the same layer — services should not depend on other services for code reuse.
  • Don't reach across layers. Controllers do not poke clients directly; services do not register routes. If you find yourself wanting to, that's usually a signal the abstraction is wrong.