Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/create-wrap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
},
"files": [
"src",
"template"
"template",
"profiles"
],
"keywords": [
"bun",
Expand Down
74 changes: 74 additions & 0 deletions packages/create-wrap/profiles/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Profiles

`bunx @donilite/create-wrap` asks what kind of project to scaffold (see
`src/profiles.ts`). Every profile is generated from the SAME base —
`packages/create-wrap/template/` — which is itself the "full-backend"
profile's file tree (Postgres + Drizzle, Redis cache, realtime, auth,
everything). This directory holds the DIFF each other profile applies on
top of that base, rather than a fully duplicated template tree per
profile — easier to keep in sync as `template/` evolves.

## Mechanics

For a given profile `<id>` (anything except `full-backend`), scaffolding
(`src/scaffold.ts`'s `scaffoldProject()`) does, in order:

1. Copy `template/` to the target directory (with `{{PLACEHOLDER}}`
substitution — same as any profile).
2. Delete every path listed in `profiles/<id>/remove.txt` (one
project-relative path per line; `#` comments and blank lines ignored).
3. Copy `profiles/<id>/files/` on top of the target directory (same
placeholder substitution, so profile files can use
`{{APP_NAME}}`/`{{APP_NAME_SNAKE}}`/`{{APP_NAME_PASCAL}}`/`{{DB_NAME}}`
too) — this both adds new files (e.g. a profile-specific feature slice)
and overwrites shared ones that need different content per profile
(`package.json`, `src/bootstrap.ts`, `src/index.ts`,
`src/config/app.config.ts`, `tests/*`, `README.md`, ...).

`full-backend` has no `remove.txt`/`files/` — it doesn't need one, it IS
the base — but it does have two yes/no follow-up prompts (Redis cache,
realtime websockets), applied as small text edits directly to the copied
template (`applyFullBackendToggles()` in `scaffold.ts`) rather than a
whole alternate file set, since each is a single conditional block.

## Adding a profile

1. Add an entry to `PROFILES` in `src/profiles.ts` (id, label, hint), and
any profile-specific follow-up prompts in `promptForAnswers()`.
2. Create `profiles/<id>/remove.txt` listing what the full-backend base
doesn't need for this profile (e.g. `drizzle.config.ts`, `compose.yml`,
`src/db`, DB-dependent tests).
3. Create `profiles/<id>/files/` with whatever replaces or adds to what
was removed — same directory layout as `template/`. At minimum this
usually means a rewritten `package.json` (drop DB-only
devDependencies/scripts), `src/bootstrap.ts` (no `initializeDatabase()`
if there's no DB), `src/config/app.config.ts` (drop the sections that
don't apply), and a profile-appropriate example feature + tests.
4. Update `src/scaffold.ts`'s `TEXT_EXTENSIONS` if the profile introduces
a new file extension that needs placeholder substitution (`.tsx` was
added for the `fullstack-ssr` profile's React components).

## Current profiles

| id | What it drops from full-backend | What it adds |
| --- | --- | --- |
| `lightweight-api` | DB, Drizzle tooling, cache/storage/email config | a DB-free `greeting` feature (`WrapService`/`RouterController`, no repository) |
| `api-aggregator` | same as lightweight-api | an `aggregator` feature calling an upstream API (fetch injected for testability) |
| `fullstack-ssr` | same as lightweight-api | TanStack Router + React, server-rendered via a Hono catch-all (`src/ssr/`) — no client hydration yet, see that profile's README |
| `gateway` | same as lightweight-api | an HTTP reverse-proxy (`hono/proxy`) + a best-effort WebSocket proxy helper (`src/gateway/ws-proxy.ts`) |

`drizzle-orm`/`drizzle-zod`/`pg` stay as dependencies in every profile,
even DB-free ones — `@donilite/wrap`'s own barrel (`entity.ts`, `events.ts`,
`dto.ts`, `database.ts`) imports them unconditionally at the module level
(e.g. `drizzle-orm`'s `getTableName`), even though establishing an actual
DB *connection* is fully opt-in (`initializeDatabase()` is never called
unless the app calls it). So these three packages are a real, unconditional
module-resolution dependency of `@donilite/wrap` itself, not just of the
full-backend profile — removing them from a DB-free profile's
`package.json` would break `import "@donilite/wrap"` at runtime with
"Cannot find package 'drizzle-orm'". What DOES get dropped for DB-free
profiles is the actual DB *tooling* that's genuinely unused without a
schema: `drizzle-kit` (migrations), `@electric-sql/pglite` (test DB),
`@types/pg`. Decoupling `@donilite/wrap`'s own barrel from a hard
`drizzle-orm` import is a real, separate refactor (lazy-loading those
modules) — flagged here as a follow-up, not attempted as part of this pass.
18 changes: 18 additions & 0 deletions packages/create-wrap/profiles/api-aggregator/files/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
JWT_SECRET="your_jwt_secret_key_here"

# Upstream API this app aggregates/fronts
EXTERNAL_API_BASE_URL="https://example.com"

# Server
PORT=5000
HOST=0.0.0.0
NODE_ENV=development

# Swagger
SWAGGER_ENABLED=true
SWAGGER_PATH=/docs
SWAGGER_TITLE={{APP_NAME}} API

# Logging
LOG_LEVEL=debug
LOG_FORMAT=text
54 changes: 54 additions & 0 deletions packages/create-wrap/profiles/api-aggregator/files/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# {{APP_NAME}}

External-API aggregator built on [@donilite/wrap](https://github.com/DoniLite/wrap#readme) — Hono + Bun, decorator-driven. **No database** — this profile is service-oriented: it declares `WrapService`s that call out to upstream APIs, fronted by `RouterController`s, mainly to get typed routes and OpenAPI docs over data this app doesn't own.

## Getting started

Prerequisite: [Bun](https://bun.sh) ≥ 1.2 — no Docker, no Postgres, no Redis needed.

```bash
bun run init:env # copy .env.example -> .env
bun run dev # http://localhost:5000/docs (Swagger UI)
bun test # test suite
```

## Project structure

```text
src/
├── bootstrap.ts # env — always the first import
├── index.ts # Hono app, middlewares, Bun.serve
├── index.controller.ts # API router (mounts each feature)
├── config/ # app configuration (env-driven, incl. externalApi.baseUrl)
├── factory/web.factory.ts # Variables + WrapRegistry augmentation
├── helpers/ # app-owned helpers (roles, ...)
├── middleware/auth.ts # auth stack, available if a route needs it
└── features/
└── aggregator/ # a vertical slice fronting an upstream API
├── DTO/ # zod-backed DTOs (SchemaDTO, not entity-derived)
├── services/ # WrapService subclass (fetch, no repository)
└── web/ # RouterController subclass (@Get, @Post, ...)
tests/ # bun:test suites (fetch stubbed, no network calls)
```

## Creating a feature

1. **DTO** — `SchemaDTO(z.object({ ... }))` shaping what you return, see `src/features/aggregator/DTO/aggregator.dto.ts`. Add a request DTO too (`CheckUpstreamRequestDTO`) for anything a caller supplies — that's what gets validated.
2. **Service** — extend `WrapService`, inject `fetch` (or an SDK client) as a constructor param so it's stubbable in tests, see `src/features/aggregator/services/aggregator.service.ts`.
3. **Controller** — extend `RouterController`, mount it from `src/index.controller.ts` with `this.register(YourController)`.

**Same `@Service()` + `@ValidateDTO()` convention as an entity-backed service** — this isn't a special DB-free variant: `AggregatorService` is `@Service()`-decorated (so `ServiceFactory.getService()` singleton-caches it, same as `BaseController` does for entity-backed services) and its `checkUpstream()` method — the one that takes caller-supplied input (a URL to check) — is `@ValidateDTO()`-decorated, validating it against `CheckUpstreamRequestDTO`'s zod schema before the method body runs. `probe()`, the internal method that takes a caller-CONTROLLED (not user-supplied) URL — `appConfig.externalApi.baseUrl` — isn't decorated, since there's nothing user-supplied to validate there. Follow the same split for your own services: `@ValidateDTO()` on anything fed from a request body/params, plain methods for anything the app itself controls.

Add more upstreams as more entries in `appConfig.externalApi` (or a map, if you front several) — see `src/config/app.config.ts`.

## Growing into a database

If this project later needs to persist data of its own (not just aggregate), look at the full-backend profile scaffolded by the same CLI (`bunx @donilite/create-wrap` → "Full backend"): copy its `src/bootstrap.ts` (adds `initializeDatabase()`), `drizzle.config.ts`, `compose.yml`, and `src/db/index.ts`, then switch a feature's `WrapService`/`RouterController` to `BaseService<Repo>`/`BaseController<Service>`.

## Useful commands

| Command | Description |
| --- | --- |
| `bun run dev` | dev server with hot reload |
| `bun test` | test suite |
| `bun run typecheck` / `bun run lint` | static checks |
38 changes: 38 additions & 0 deletions packages/create-wrap/profiles/api-aggregator/files/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "wrap-template",
"module": "index.ts",
"type": "module",
"private": true,
"scripts": {
"dev": "bun --hot src/index.ts",
"start": "NODE_ENV=production bun run src/index.ts",
"fmt": "prettier --write .",
"typecheck": "tsc --noEmit",
"lint": "bunx eslint .",
"lint:fix": "bunx eslint . --ext .js,.ts --fix",
"init:env": "cp ./.env.example ./.env",
"init:all": "bun init:env && bun install",
"test": "bun test"
},
"devDependencies": {
"@eslint/js": "^10",
"@types/bun": "latest",
"eslint": "^10",
"globals": "^17.0.0",
"jiti": "^2.7.0",
"prettier": "^3.9.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.62.1"
},
"dependencies": {
"@donilite/wrap": "workspace:*",
"dotenv": "^17.2.0",
"dotenv-expand": "^13",
"drizzle-orm": "^0.45.0",
"drizzle-zod": "^0.8.3",
"hono": "^4.12.27",
"pg": "^8.16.0",
"reflect-metadata": "^0.2.2",
"zod": "^4.4.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* App bootstrap — MUST be the first import of src/index.ts.
*
* Lightweight-API profile: no database, no cache backend to configure —
* `@donilite/wrap`'s repository/cache machinery is entirely opt-in (see
* `RouterController`/`WrapService`, which don't require either), so there
* is nothing here beyond loading the environment. If this project later
* needs Postgres, add `initializeDatabase({...})` back the way the
* full-backend profile does (see that profile's `src/bootstrap.ts`).
*/
import { config } from "dotenv";
import { expand } from "dotenv-expand";

expand(config());
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { logger } from "@donilite/wrap";

/**
* Application configuration — API-aggregator profile: no `database`,
* `storage` or `email` sections (this app owns no data of its own), plus
* an `externalApi` section for the upstream(s) it fronts.
*/
export interface AppConfig {
// Server
port: number;
host: string;
env: "development" | "production" | "test";

// The upstream API this app aggregates/fronts — add more entries here
// (or a map) as you front more than one upstream.
externalApi: {
baseUrl: string;
};

// JWT
jwt: {
secret: string;
expiresIn: string;
};

// Rate Limiting
rateLimit: {
enabled: boolean;
max: number;
window: number;
};

// Cache (in-memory — no Redis in this profile)
cache: {
enabled: boolean;
ttl: number;
};

// Logging
logging: {
level: "debug" | "info" | "warn" | "error";
format: "json" | "text";
};

// Swagger
swagger: {
enabled: boolean;
path: string;
title: string;
version: string;
};

// Cors
cors: {
origin: string[];
credentials: boolean;
};
}

export const appConfig: AppConfig = {
port: Number(process.env.PORT) || 5000,
host: process.env.HOST || "0.0.0.0",
env: (process.env.NODE_ENV as AppConfig["env"]) || "development",

externalApi: {
baseUrl: process.env.EXTERNAL_API_BASE_URL || "https://example.com",
},

jwt: {
secret: process.env.JWT_SECRET || "your-secret-key",
expiresIn: process.env.JWT_EXPIRES_IN || "5h",
},

rateLimit: {
enabled: process.env.RATE_LIMIT_ENABLED !== "false",
max: Number(process.env.RATE_LIMIT_MAX) || 100,
window: Number(process.env.RATE_LIMIT_WINDOW) || 60,
},

cache: {
enabled: process.env.CACHE_ENABLED !== "false",
ttl: Number(process.env.CACHE_TTL) || 300,
},

logging: {
level: (process.env.LOG_LEVEL as AppConfig["logging"]["level"]) || "info",
format:
(process.env.LOG_FORMAT as AppConfig["logging"]["format"]) || "text",
},

swagger: {
enabled: process.env.SWAGGER_ENABLED !== "false",
path: process.env.SWAGGER_PATH || "/docs",
title: process.env.SWAGGER_TITLE || "API Documentation",
version: process.env.SWAGGER_VERSION || "1.0.0",
},

cors: {
origin: (process.env.CORS_ORIGIN || "*").split(",").map((origin) => {
logger.info(`Adding CORS origin: ${origin}`);
return origin.trim();
}),
credentials: process.env.CORS_CREDENTIALS === "true",
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createFactory } from "hono/factory";
import type { JwtVariables } from "hono/jwt";
import type { AppVariables } from "@donilite/wrap";
import type { UserRoles } from "@/helpers/access.helper";

/** Custom context variables this app contributes — the framework merges in
* its own (e.g. `identity`, set by AuthController) automatically. */
export type Variables = {
// Add custom context variables here
} & JwtVariables;

/**
* Register the app types into the framework. No `schema` entry here —
* this profile has no drizzle schema (no DB); `RegisteredSchema` falls
* back to `Record<string, never>` when `schema` isn't registered (see
* `@donilite/wrap`'s `registry.ts`). Add it back if this project grows a
* database (see the full-backend profile's `web.factory.ts`).
*/
declare module "@donilite/wrap" {
interface WrapRegistry {
variables: Variables;
roles: UserRoles;
}
}

// Typed with the framework-merged `AppVariables` (Variables + identity, see
// registry.ts), not the bare `Variables` above — every Hono app a
// controller builds needs to agree with what AuthController/Wrap expect.
export const webFactory = createFactory<{
Variables: AppVariables;
}>();
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from "zod";
import { DTO, SchemaDTO } from "@donilite/wrap";

/**
* No entity/repository in this profile — `SchemaDTO()` builds a DTO
* straight from a zod schema (validation + OpenAPI) for a feature whose
* data comes from an upstream API instead of this app's own table.
*/
@DTO()
export class UpstreamStatusDTO extends SchemaDTO(
z.object({
upstream: z.string(),
ok: z.boolean(),
checkedAt: z.string(),
}),
) {}

/**
* User-supplied input for the validated `POST /aggregator/status` route —
* see `AggregatorService.checkUpstream()`, `@ValidateDTO()`-decorated.
*/
@DTO()
export class CheckUpstreamRequestDTO extends SchemaDTO(
z.object({
url: z.string().url(),
}),
) {}
Loading
Loading