diff --git a/packages/create-wrap/package.json b/packages/create-wrap/package.json index ee3b342..ad27169 100644 --- a/packages/create-wrap/package.json +++ b/packages/create-wrap/package.json @@ -9,7 +9,8 @@ }, "files": [ "src", - "template" + "template", + "profiles" ], "keywords": [ "bun", diff --git a/packages/create-wrap/profiles/README.md b/packages/create-wrap/profiles/README.md new file mode 100644 index 0000000..1f8e75b --- /dev/null +++ b/packages/create-wrap/profiles/README.md @@ -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 `` (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//remove.txt` (one + project-relative path per line; `#` comments and blank lines ignored). +3. Copy `profiles//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//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//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. diff --git a/packages/create-wrap/profiles/api-aggregator/files/.env.example b/packages/create-wrap/profiles/api-aggregator/files/.env.example new file mode 100644 index 0000000..c552aea --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/.env.example @@ -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 diff --git a/packages/create-wrap/profiles/api-aggregator/files/README.md b/packages/create-wrap/profiles/api-aggregator/files/README.md new file mode 100644 index 0000000..a9bee29 --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/README.md @@ -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`/`BaseController`. + +## Useful commands + +| Command | Description | +| --- | --- | +| `bun run dev` | dev server with hot reload | +| `bun test` | test suite | +| `bun run typecheck` / `bun run lint` | static checks | diff --git a/packages/create-wrap/profiles/api-aggregator/files/package.json b/packages/create-wrap/profiles/api-aggregator/files/package.json new file mode 100644 index 0000000..ef67681 --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/package.json @@ -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" + } +} diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/bootstrap.ts b/packages/create-wrap/profiles/api-aggregator/files/src/bootstrap.ts new file mode 100644 index 0000000..ab9f5ab --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/bootstrap.ts @@ -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()); diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/config/app.config.ts b/packages/create-wrap/profiles/api-aggregator/files/src/config/app.config.ts new file mode 100644 index 0000000..96d675e --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/config/app.config.ts @@ -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", + }, +}; diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/factory/web.factory.ts b/packages/create-wrap/profiles/api-aggregator/files/src/factory/web.factory.ts new file mode 100644 index 0000000..d3344ea --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/factory/web.factory.ts @@ -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` 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; +}>(); diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/DTO/aggregator.dto.ts b/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/DTO/aggregator.dto.ts new file mode 100644 index 0000000..2050bca --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/DTO/aggregator.dto.ts @@ -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(), + }), +) {} diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/services/aggregator.service.ts b/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/services/aggregator.service.ts new file mode 100644 index 0000000..256d7d9 --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/services/aggregator.service.ts @@ -0,0 +1,57 @@ +import { Service, ValidateDTO, WrapService } from "@donilite/wrap"; +import type { Context } from "hono"; +import type { CheckUpstreamRequestDTO, UpstreamStatusDTO } from "../DTO/aggregator.dto"; + +/** + * `WrapService` — no repository/entity, this feature's data is an + * upstream HTTP API rather than this app's own table. The API-aggregator + * profile is meant for apps that mostly declare services like this one + * (fetch + shape + validate) fronted by controllers, purely to get typed + * routes and OpenAPI docs over calls this app doesn't own the data for. + * + * Same `@Service()` + `@ValidateDTO()` convention as an entity-backed + * `BaseService`, even without a repository: `@Service()` registers the + * class with `ServiceFactory` (singleton lookup, same as + * `BaseController`-style controllers use); `@ValidateDTO()` on + * `checkUpstream()` validates the caller-supplied URL against + * `CheckUpstreamRequestDTO`'s zod schema before the method body runs — + * both decorators are fully generic in `@donilite/wrap`, no dependency on + * a repository. Follow the same shape for your own upstream-calling + * services. + * + * `fetchImpl` is constructor-injectable so tests don't hit the network — + * see tests/wrap.test.ts, which passes a stub. + */ +@Service() +export class AggregatorService extends WrapService { + constructor(private readonly fetchImpl: typeof fetch = fetch) { + super(); + } + + /** Validated entry point — `url` comes from the request body, see `web/aggregator.controller.ts`'s `checkCustom()`. */ + @ValidateDTO() + async checkUpstream( + dto: CheckUpstreamRequestDTO, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _c: Context, + ): Promise { + return this.probe(dto.url); + } + + /** Unvalidated entry point for a caller-controlled URL (e.g. `appConfig.externalApi.baseUrl` — not user input, nothing to validate). */ + async probe(upstreamUrl: string): Promise { + this.logger.debug("Checking upstream", { upstreamUrl }); + let ok = false; + try { + const res = await this.fetchImpl(upstreamUrl, { method: "HEAD" }); + ok = res.ok; + } catch (e) { + this.logger.warn("Upstream check failed", { upstreamUrl }, e); + } + return { + upstream: upstreamUrl, + ok, + checkedAt: new Date().toISOString(), + } as UpstreamStatusDTO; + } +} diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/web/aggregator.controller.ts b/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/web/aggregator.controller.ts new file mode 100644 index 0000000..6a66c0a --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/web/aggregator.controller.ts @@ -0,0 +1,69 @@ +import { + ApiResponse, + Controller, + Get, + Post, + RouterController, + ServiceFactory, + Serialize, +} from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { appConfig } from "@/config/app.config"; +import { CheckUpstreamRequestDTO, UpstreamStatusDTO } from "../DTO/aggregator.dto"; +import { AggregatorService } from "../services/aggregator.service"; + +/** + * `RouterController` — no repository, no entity. Exposes typed, documented + * routes over data this app aggregates from an upstream API rather than + * owning itself. Add one controller like this per upstream you front. + * + * `ServiceFactory.getService()` (not `new AggregatorService()`) — same + * singleton lookup an entity-backed `BaseController` uses, since + * `AggregatorService` is `@Service()`-decorated. + */ +@Controller({ + basePath: "/aggregator", + tags: ["Aggregator"], + description: "Example external-API aggregation endpoints", +}) +export class AggregatorController extends RouterController { + private readonly service = ServiceFactory.getService(AggregatorService); + + constructor() { + super(webFactory.createApp()); + } + + @Get({ + path: "/status", + description: "Check the configured default upstream API's reachability", + }) + @ApiResponse(200, { description: "Success", schema: UpstreamStatusDTO }) + @Serialize(UpstreamStatusDTO) + async status(c: Context) { + try { + const result = await this.service.probe(appConfig.externalApi.baseUrl); + return c.json(result); + } catch (e) { + return this.handleError(c, e); + } + } + + @Post({ + path: "/status", + description: "Check an arbitrary upstream URL's reachability", + body: CheckUpstreamRequestDTO, + }) + @ApiResponse(200, { description: "Success", schema: UpstreamStatusDTO }) + @Serialize(UpstreamStatusDTO) + async checkCustom(c: Context) { + try { + const body = await c.req.json(); + const dto = CheckUpstreamRequestDTO.from(body); + const result = await this.service.checkUpstream(dto, c); + return c.json(result); + } catch (e) { + return this.handleError(c, e); + } + } +} diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/index.controller.ts b/packages/create-wrap/profiles/api-aggregator/files/src/index.controller.ts new file mode 100644 index 0000000..7203367 --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/index.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, RouterController } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { AggregatorController } from "@/features/aggregator/web/aggregator.controller"; + +/** + * Root controller: owns health/root routes and composes feature + * controllers as children — the only controller registered on `Wrap` + * itself (see src/index.ts). Each child keeps its own + * `@Controller({ basePath })` as the single source of truth for where it + * lives; add new features here as `this.register(SomeController)`. + */ +@Controller({ + basePath: "/", + tags: ["Health"], + description: "Service health and root routes", +}) +export class IndexController extends RouterController { + constructor() { + super(webFactory.createApp()); + this.register(AggregatorController); + } + + @Get({ path: "/", description: "Health check" }) + async health(c: Context) { + return c.json({ status: "ok" }); + } +} diff --git a/packages/create-wrap/profiles/api-aggregator/files/src/index.ts b/packages/create-wrap/profiles/api-aggregator/files/src/index.ts new file mode 100644 index 0000000..bc698ff --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/src/index.ts @@ -0,0 +1,40 @@ +import "reflect-metadata"; +import "./bootstrap"; // env — must stay first +import { Wrap } from "@donilite/wrap"; +import { IndexController } from "@/index.controller"; +import { auth } from "@/middleware/auth"; +import { appConfig } from "@/config/app.config"; + +const app = new Wrap({ + cors: { + origin: appConfig.cors.origin, + credentials: appConfig.cors.credentials, + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + }, +}); + +// Auth: made available to `.swagger()` for the generated spec's security +// schemes; guards apply per route group, same as any other middleware. +app.with(auth); + +// Controllers self-register at their own @Controller basePath. +app.register(IndexController); + +if (appConfig.swagger.enabled) { + app.swagger({ + path: appConfig.swagger.path, + title: appConfig.swagger.title, + version: appConfig.swagger.version, + description: "API documentation with enhanced features", + servers: [ + { + url: `http://${appConfig.host === "0.0.0.0" ? "localhost" : appConfig.host}:${appConfig.port}`, + description: (process.env.NODE_ENV || "development").toUpperCase(), + }, + ], + }); +} + +const server = app.listen(appConfig.port, appConfig.host); + +console.log(`⚡ ready on http://localhost:${server.port}`); diff --git a/packages/create-wrap/profiles/api-aggregator/files/tests/swagger.test.ts b/packages/create-wrap/profiles/api-aggregator/files/tests/swagger.test.ts new file mode 100644 index 0000000..dddfe2c --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/tests/swagger.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "bun:test"; +import { SwaggerGenerator, Wrap } from "@donilite/wrap"; +// Side-effect import: registers @Controller/@Route metadata used below. +import "@/index.controller"; +import { IndexController } from "@/index.controller"; + +// AggregatorController's field initializer resolves AggregatorService +// through `ServiceFactory.getService()`, a process-wide singleton cache — +// the FIRST construction anywhere in the test run wins and is reused by +// every subsequent `it()` across every test file, including +// tests/wrap.test.ts's. Since this file is the one that constructs +// IndexController (and therefore AggregatorController) at module-eval +// time (see the `new Wrap().register(IndexController)` below, which runs +// during bun:test's collection phase, before any `it()` anywhere +// executes), stubbing `fetch` here — before that line — is what keeps +// the ENTIRE suite from ever making a real network call through the +// cached singleton, regardless of which test file happens to touch the +// aggregator routes first. +globalThis.fetch = (async () => + new Response(null, { status: 200 })) as unknown as typeof fetch; + +// Registering on a Wrap is what makes resolveControllerPath() have +// anything recorded to walk (see @donilite/wrap's swagger generator). +new Wrap().register(IndexController); + +describe("SwaggerGenerator (api-aggregator profile, no DB)", () => { + it("documents the aggregator routes without needing a database", () => { + const generator = new SwaggerGenerator({ title: "Test", version: "0.0.0" }); + const spec = generator.generateSpec(); + + expect(spec.paths["/aggregator/status"]?.get).toBeDefined(); + expect( + spec.tags.some((t: { name: string }) => t.name === "Aggregator"), + ).toBe(true); + }); +}); diff --git a/packages/create-wrap/profiles/api-aggregator/files/tests/wrap.test.ts b/packages/create-wrap/profiles/api-aggregator/files/tests/wrap.test.ts new file mode 100644 index 0000000..6f40632 --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/files/tests/wrap.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "bun:test"; +import { requestJson } from "@donilite/wrap/testing"; +import { Controller, Get, JwtCookieAuthController, RouterController, Wrap } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { IndexController } from "@/index.controller"; +import { AggregatorService } from "@/features/aggregator/services/aggregator.service"; + +// Exercises the actual production wiring (Wrap -> IndexController -> +// AggregatorController as a child), not a direct `new AggregatorController()` +// bypass — this is what src/index.ts really boots. No database anywhere: +// this profile aggregates an upstream API rather than owning its own data. +describe("Wrap composition root (api-aggregator profile, no DB)", () => { + it("serves the health route registered on IndexController", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); + + it("serves AggregatorController's default (unvalidated, config-driven) route as a child registered by IndexController", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/aggregator/status"); + expect(res.status).toBe(200); + expect(res.body.upstream).toBeDefined(); + expect(typeof res.body.ok).toBe("boolean"); + }); + + it("serves the validated POST route, @ValidateDTO() on AggregatorService.checkUpstream() runs even without a repository", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "POST", "/aggregator/status", { + url: "https://example.com", + }); + expect(res.status).toBe(200); + expect(res.body.upstream).toBe("https://example.com"); + }); + + it("rejects an invalid URL on the validated POST route", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "POST", "/aggregator/status", { + url: "not-a-url", + }); + expect(res.status).toBe(400); + }); + + it("returns the standard 404 shape for unknown routes", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/nope"); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); +}); + +describe("AggregatorService", () => { + it("reports ok:true when the upstream responds successfully (fetch stubbed, no network)", async () => { + const stubbedFetch = (async () => + new Response(null, { status: 200 })) as unknown as typeof fetch; + const service = new AggregatorService(stubbedFetch); + + const result = await service.probe("https://example.com"); + expect(result.ok).toBe(true); + expect(result.upstream).toBe("https://example.com"); + }); + + it("reports ok:false when the upstream call throws", async () => { + const failingFetch = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + const service = new AggregatorService(failingFetch); + + const result = await service.probe("https://example.com"); + expect(result.ok).toBe(false); + }); +}); + +@Controller({ basePath: "/occupants-like-child" }) +class OrderTestChildController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: "/" }) + async list(c: Context) { + return c.json({ matched: "child" }); + } +} + +@Controller({ basePath: "/parent-with-id" }) +class OrderTestParentController extends RouterController { + constructor() { + super(webFactory.createApp()); + // Mirrors a real bug: a parent whose own route has a :param segment, + // composing a child at a static prefix. Hono's router is purely + // registration-order-dependent for overlapping patterns — if the + // parent's own :id route were registered before the child is mounted, + // it would swallow every request meant for the child (":id" matches + // the literal string "occupants-like-child" too). + this.register(OrderTestChildController); + } + + @Get({ path: "/:id" }) + async byId(c: Context) { + return c.json({ matched: "parent", id: c.req.param("id") }); + } +} + +describe("parent → children registration order", () => { + it("a child's static-prefix route is not swallowed by the parent's own :param route", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const childRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/occupants-like-child", + ); + expect(childRes.status).toBe(200); + expect(childRes.body).toEqual({ matched: "child" }); + }); + + it("the parent's own :param route still matches a genuine id", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const parentRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/some-real-id", + ); + expect(parentRes.status).toBe(200); + expect(parentRes.body).toEqual({ matched: "parent", id: "some-real-id" }); + }); +}); + +describe("JwtCookieAuthController", () => { + it("exposes an OpenAPI security scheme", () => { + const auth = new JwtCookieAuthController({ secret: "wrap-test" }); + const schemes = auth.openApiSecurityScheme(); + expect(Object.keys(schemes)).toEqual(["bearerAuth", "cookieAuth"]); + }); +}); diff --git a/packages/create-wrap/profiles/api-aggregator/remove.txt b/packages/create-wrap/profiles/api-aggregator/remove.txt new file mode 100644 index 0000000..a113c0a --- /dev/null +++ b/packages/create-wrap/profiles/api-aggregator/remove.txt @@ -0,0 +1,17 @@ +# Paths (relative to the scaffolded project root) deleted after the base +# template is copied, before this profile's files/ overlay is applied. +# Blank lines and lines starting with # are ignored. + +drizzle.config.ts +compose.yml +src/db +src/features/example +tests/cache.test.ts +tests/example.controller.test.ts +tests/example.repository.sync.test.ts +tests/example.repository.test.ts +tests/example.service.test.ts +tests/realtime.test.ts +tests/swagger.test.ts +tests/transactions.test.ts +tests/wrap.test.ts diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/.env.example b/packages/create-wrap/profiles/fullstack-ssr/files/.env.example new file mode 100644 index 0000000..e1ba4fc --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/.env.example @@ -0,0 +1,15 @@ +JWT_SECRET="your_jwt_secret_key_here" + +# 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 diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/README.md b/packages/create-wrap/profiles/fullstack-ssr/files/README.md new file mode 100644 index 0000000..a5a7b99 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/README.md @@ -0,0 +1,57 @@ +# {{APP_NAME}} + +Fullstack SSR app built on [@donilite/wrap](https://github.com/DoniLite/wrap#readme) — Hono + Bun backend, [TanStack Router](https://tanstack.com/router) + React pages, rendered server-side from the same app. **No database** by default — add one (see below) if this app needs to persist data alongside its pages. + +## What this profile is — and isn't — set up for + +**Ships, working end to end:** an HTTP request hits a Hono catch-all route (`src/index.ts`), TanStack Router resolves which page matches (`src/ssr/routes.tsx`), and React renders it to an HTML string server-side (`src/ssr/render.tsx`). `bun run dev` and load `http://localhost:5000/` to see it. + +**Does NOT ship:** client-side hydration. There's no client JS bundle wired up (no Vite/esbuild build step in this template) — pages are server-rendered HTML only, no `hydrateRoot()`, no client-side interactivity beyond plain links/forms/browser-native behavior. Wiring a client bundle is real, separate work (pick a bundler, add a client entry point that calls `hydrateRoot()` with the same route tree, serve the built assets as static files) — intentionally left as a follow-up rather than guessed at. `src/ssr/render.tsx`'s header comment has the same note. + +## Getting started + +Prerequisite: [Bun](https://bun.sh) ≥ 1.2 — no Docker, no Postgres, no Redis needed (unless you add a database — see below). + +```bash +bun run init:env # copy .env.example -> .env +bun run dev # http://localhost:5000 (SSR pages), /docs (Swagger UI for /api routes) +bun test # test suite +``` + +## Project structure + +```text +src/ +├── bootstrap.ts # env — always the first import +├── index.ts # Hono app: /api routes, Swagger, SSR catch-all, Bun.serve +├── index.controller.ts # JSON API router, mounted at /api (not / — SSR owns /) +├── config/ # app configuration (env-driven) +├── factory/web.factory.ts # Variables + WrapRegistry augmentation +├── helpers/ # app-owned helpers (roles, ...) +├── middleware/auth.ts # auth stack, usable on /api routes +└── ssr/ + ├── routes.tsx # TanStack Router route tree — add pages here + ├── router.ts # per-request router factory (createMemoryHistory) + └── render.tsx # renderPage(): route match + React SSR -> HTML string +tests/ # bun:test suites (SSR + /api) +``` + +## Adding a page + +Add a route to `src/ssr/routes.tsx` (`createRoute({ getParentRoute: () => rootRoute, path: "/your-path", component: () => })`) and add it to `rootRoute.addChildren([...])`. That's it — the catch-all in `src/index.ts` picks it up automatically. + +## Adding a JSON API route + +Same pattern as the other profiles: extend `RouterController` (or `BaseController` once you have a DB), mount it from `src/index.controller.ts`. Keep API routes under `/api` so they don't collide with SSR page paths. + +## Growing into a database + +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`. + +## Useful commands + +| Command | Description | +| --- | --- | +| `bun run dev` | dev server with hot reload | +| `bun test` | test suite | +| `bun run typecheck` / `bun run lint` | static checks | diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/package.json b/packages/create-wrap/profiles/fullstack-ssr/files/package.json new file mode 100644 index 0000000..47d9d94 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/package.json @@ -0,0 +1,43 @@ +{ + "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", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "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:*", + "@tanstack/react-router": "^1.170.17", + "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", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "reflect-metadata": "^0.2.2", + "zod": "^4.4.3" + } +} diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/bootstrap.ts b/packages/create-wrap/profiles/fullstack-ssr/files/src/bootstrap.ts new file mode 100644 index 0000000..ab9f5ab --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/bootstrap.ts @@ -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()); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/config/app.config.ts b/packages/create-wrap/profiles/fullstack-ssr/files/src/config/app.config.ts new file mode 100644 index 0000000..35c76ef --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/config/app.config.ts @@ -0,0 +1,96 @@ +import { logger } from "@donilite/wrap"; + +/** + * Application configuration — Fullstack-SSR profile: no `database`, + * `storage` or `email` sections by default (the app is scaffolded without + * a DB; add these back, same shape as the full-backend profile, if the + * project needs to persist data alongside its SSR pages). + */ +export interface AppConfig { + // Server + port: number; + host: string; + env: "development" | "production" | "test"; + + // 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", + + 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", + }, +}; diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/factory/web.factory.ts b/packages/create-wrap/profiles/fullstack-ssr/files/src/factory/web.factory.ts new file mode 100644 index 0000000..d3344ea --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/factory/web.factory.ts @@ -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` 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; +}>(); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/index.controller.ts b/packages/create-wrap/profiles/fullstack-ssr/files/src/index.controller.ts new file mode 100644 index 0000000..adf485b --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/index.controller.ts @@ -0,0 +1,26 @@ +import { Controller, Get, RouterController } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; + +/** + * API router — mounted at `/api`, NOT `/`, so it doesn't collide with the + * SSR catch-all (`app.get("*", ...)` in `src/index.ts`) that renders + * TanStack Router pages at "/" and below. Add JSON API routes here or as + * children the same way the other profiles do; add pages in + * `src/ssr/routes.tsx` instead. + */ +@Controller({ + basePath: "/api", + tags: ["Health"], + description: "Service health and JSON API routes", +}) +export class IndexController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: "/health", description: "Health check" }) + async health(c: Context) { + return c.json({ status: "ok" }); + } +} diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/index.ts b/packages/create-wrap/profiles/fullstack-ssr/files/src/index.ts new file mode 100644 index 0000000..6718fe7 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/index.ts @@ -0,0 +1,52 @@ +import "reflect-metadata"; +import "./bootstrap"; // env — must stay first +import { Wrap } from "@donilite/wrap"; +import { IndexController } from "@/index.controller"; +import { auth } from "@/middleware/auth"; +import { appConfig } from "@/config/app.config"; +import { renderPage } from "@/ssr/render"; + +const app = new Wrap({ + cors: { + origin: appConfig.cors.origin, + credentials: appConfig.cors.credentials, + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + }, +}); + +// Auth: made available to `.swagger()` for the generated spec's security +// schemes; guards apply per route group, same as any other middleware. +app.with(auth); + +// JSON API routes, mounted under /api — controllers self-register at +// their own @Controller basePath. +app.register(IndexController); + +if (appConfig.swagger.enabled) { + app.swagger({ + path: appConfig.swagger.path, + title: appConfig.swagger.title, + version: appConfig.swagger.version, + description: "API documentation with enhanced features", + servers: [ + { + url: `http://${appConfig.host === "0.0.0.0" ? "localhost" : appConfig.host}:${appConfig.port}`, + description: (process.env.NODE_ENV || "development").toUpperCase(), + }, + ], + }); +} + +// SSR catch-all — a plain Hono route, no new Wrap primitive needed. Comes +// AFTER the API/docs routes above so it never shadows them; TanStack +// Router (src/ssr/routes.tsx) resolves the actual page for the request +// path. See src/ssr/render.tsx for what's shipped (server render only, no +// client hydration yet). +app.get("*", async (c) => { + const html = await renderPage(c); + return c.html(html); +}); + +const server = app.listen(appConfig.port, appConfig.host); + +console.log(`⚡ ready on http://localhost:${server.port}`); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/render.tsx b/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/render.tsx new file mode 100644 index 0000000..3c7b69c --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/render.tsx @@ -0,0 +1,31 @@ +import { renderToString } from "react-dom/server"; +import { RouterProvider } from "@tanstack/react-router"; +import type { Context } from "hono"; +import { createAppRouter } from "./router"; + +/** + * Mounts TanStack Router's SSR render as an ordinary Hono route — this is + * the actual mechanism the fullstack-ssr profile is built around: `Wrap` + * doesn't gain a new primitive for this, a catch-all `app.get("*", ...)` + * (see `src/index.ts`) is all that's needed, same as any other route. + * + * IMPORTANT — what this profile ships vs. what it doesn't: + * - Ships: server-side route matching + rendering (this file), a real, + * runnable round trip from an HTTP request to server-rendered HTML. + * - Does NOT ship: client-side hydration. There's no client JS bundle + * wired up (no Vite/esbuild build step in this template), so pages are + * server-rendered HTML only — no `hydrateRoot()`, no interactivity + * beyond plain links/forms. Wiring a client bundle + hydration is a + * genuine, separate piece of work (a bundler config + a client entry + * point) intentionally left as a follow-up rather than guessed at + * unsupervised — see the project README for the pointer. + */ +export async function renderPage(c: Context): Promise { + const router = createAppRouter(c.req.path); + await router.load(); + + const appHtml = renderToString(); + // The root route's own `component` already renders // + // (see src/ssr/routes.tsx), so `appHtml` is the full document string. + return `${appHtml}`; +} diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/router.ts b/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/router.ts new file mode 100644 index 0000000..3712ac4 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/router.ts @@ -0,0 +1,16 @@ +import { createMemoryHistory, createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routes"; + +/** + * Build a fresh router per SSR request — a TanStack Router instance holds + * per-navigation state, so sharing one across concurrent requests would + * leak state between them. `createMemoryHistory` seeds it at the + * requested URL instead of the browser's `window.location` (there is no + * browser here). + */ +export function createAppRouter(url: string) { + return createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [url] }), + }); +} diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/routes.tsx b/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/routes.tsx new file mode 100644 index 0000000..d29e0c5 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/routes.tsx @@ -0,0 +1,53 @@ +import { createRootRoute, createRoute, Link, Outlet } from "@tanstack/react-router"; + +/** + * TanStack Router route tree — the pages themselves live here, same as a + * client-only TanStack Router app. What's different in this profile is + * WHO renders them and WHEN: see `src/ssr/render.tsx`, which resolves this + * tree server-side (in a Hono route handler) instead of in the browser. + * + * Add pages by adding routes here and wiring them into `routeTree`'s + * children — same shape you'd use in a client-only TanStack Router app. + */ +export const rootRoute = createRootRoute({ + component: () => ( + + + + {{APP_NAME}} + + + +
+ +
+ + + ), +}); + +const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: () => ( +
+

{{APP_NAME}}

+

Rendered server-side by Hono, routed by TanStack Router.

+
+ ), +}); + +const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/about", + component: () => ( +
+

About

+

A second page — add more routes the same way in this file.

+
+ ), +}); + +export const routeTree = rootRoute.addChildren([homeRoute, aboutRoute]); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/tests/ssr.test.ts b/packages/create-wrap/profiles/fullstack-ssr/files/tests/ssr.test.ts new file mode 100644 index 0000000..2fbfea7 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/tests/ssr.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "bun:test"; +import { Hono } from "hono"; +import { renderPage } from "@/ssr/render"; + +// Exercises the actual SSR round trip: an HTTP request -> Hono catch-all -> +// TanStack Router route matching -> React server render -> HTML. Doesn't +// cover client hydration — there isn't any yet (see src/ssr/render.tsx's +// header comment and the project README). +function buildApp() { + const app = new Hono(); + app.get("*", async (c) => { + const html = await renderPage(c); + return c.html(html); + }); + return app; +} + +describe("SSR render (fullstack-ssr profile)", () => { + it("renders the home route at /", async () => { + const res = await buildApp().request("/"); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain(""); + expect(html).toMatch(/Rendered server-side by Hono/); + }); + + it("renders the about route at /about", async () => { + const res = await buildApp().request("/about"); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toMatch(/About/); + }); +}); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/tests/swagger.test.ts b/packages/create-wrap/profiles/fullstack-ssr/files/tests/swagger.test.ts new file mode 100644 index 0000000..048cdca --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/tests/swagger.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "bun:test"; +import { SwaggerGenerator, Wrap } from "@donilite/wrap"; +// Side-effect import: registers @Controller/@Route metadata used below. +import "@/index.controller"; +import { IndexController } from "@/index.controller"; + +// Registering on a Wrap is what makes resolveControllerPath() have +// anything recorded to walk (see @donilite/wrap's swagger generator). +new Wrap().register(IndexController); + +describe("SwaggerGenerator (fullstack-ssr profile, API side)", () => { + it("documents the API health route (mounted under /api, not /, so it doesn't collide with SSR pages)", () => { + const generator = new SwaggerGenerator({ title: "Test", version: "0.0.0" }); + const spec = generator.generateSpec(); + + expect(spec.paths["/api/health"]?.get).toBeDefined(); + }); +}); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/tests/wrap.test.ts b/packages/create-wrap/profiles/fullstack-ssr/files/tests/wrap.test.ts new file mode 100644 index 0000000..2fe9de1 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/tests/wrap.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "bun:test"; +import { requestJson } from "@donilite/wrap/testing"; +import { Controller, Get, RouterController, Wrap } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { IndexController } from "@/index.controller"; + +// Exercises the actual production wiring (Wrap -> IndexController), not a +// direct `new IndexController()` bypass — this is what src/index.ts really +// boots for the JSON API side. No database anywhere: this profile isn't +// scaffolded with one by default. SSR page rendering is covered separately +// in tests/ssr.test.ts (renderPage() directly, not through Wrap, since the +// catch-all route lives in src/index.ts's module scope, not IndexController). +describe("Wrap composition root (fullstack-ssr profile, API side)", () => { + it("serves the health route registered on IndexController", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/api/health"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); + + it("returns the standard 404 shape for unknown API routes", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/api/nope"); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); +}); + +@Controller({ basePath: "/occupants-like-child" }) +class OrderTestChildController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: "/" }) + async list(c: Context) { + return c.json({ matched: "child" }); + } +} + +@Controller({ basePath: "/parent-with-id" }) +class OrderTestParentController extends RouterController { + constructor() { + super(webFactory.createApp()); + // Mirrors a real bug: a parent whose own route has a :param segment, + // composing a child at a static prefix. Hono's router is purely + // registration-order-dependent for overlapping patterns — if the + // parent's own :id route were registered before the child is mounted, + // it would swallow every request meant for the child (":id" matches + // the literal string "occupants-like-child" too). + this.register(OrderTestChildController); + } + + @Get({ path: "/:id" }) + async byId(c: Context) { + return c.json({ matched: "parent", id: c.req.param("id") }); + } +} + +describe("parent → children registration order", () => { + it("a child's static-prefix route is not swallowed by the parent's own :param route", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const childRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/occupants-like-child", + ); + expect(childRes.status).toBe(200); + expect(childRes.body).toEqual({ matched: "child" }); + }); + + it("the parent's own :param route still matches a genuine id", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const parentRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/some-real-id", + ); + expect(parentRes.status).toBe(200); + expect(parentRes.body).toEqual({ matched: "parent", id: "some-real-id" }); + }); +}); diff --git a/packages/create-wrap/profiles/fullstack-ssr/files/tsconfig.json b/packages/create-wrap/profiles/fullstack-ssr/files/tsconfig.json new file mode 100644 index 0000000..04720f9 --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/files/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "types": ["bun"], + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "jsxImportSource": "react", + "allowJs": true, + + "paths": { + "@/*": ["./src/*"] + }, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true + } +} diff --git a/packages/create-wrap/profiles/fullstack-ssr/remove.txt b/packages/create-wrap/profiles/fullstack-ssr/remove.txt new file mode 100644 index 0000000..a113c0a --- /dev/null +++ b/packages/create-wrap/profiles/fullstack-ssr/remove.txt @@ -0,0 +1,17 @@ +# Paths (relative to the scaffolded project root) deleted after the base +# template is copied, before this profile's files/ overlay is applied. +# Blank lines and lines starting with # are ignored. + +drizzle.config.ts +compose.yml +src/db +src/features/example +tests/cache.test.ts +tests/example.controller.test.ts +tests/example.repository.sync.test.ts +tests/example.repository.test.ts +tests/example.service.test.ts +tests/realtime.test.ts +tests/swagger.test.ts +tests/transactions.test.ts +tests/wrap.test.ts diff --git a/packages/create-wrap/profiles/gateway/files/.env.example b/packages/create-wrap/profiles/gateway/files/.env.example new file mode 100644 index 0000000..41ef926 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/.env.example @@ -0,0 +1,18 @@ +JWT_SECRET="your_jwt_secret_key_here" + +# HTTP proxy target — see src/features/proxy/web/proxy.controller.ts +UPSTREAM_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 diff --git a/packages/create-wrap/profiles/gateway/files/README.md b/packages/create-wrap/profiles/gateway/files/README.md new file mode 100644 index 0000000..d4966a2 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/README.md @@ -0,0 +1,50 @@ +# {{APP_NAME}} + +Proxy/gateway app built on [@donilite/wrap](https://github.com/DoniLite/wrap#readme) — Hono + Bun, decorator-driven. **No database** — this profile fronts other services (HTTP reverse-proxy + a best-effort WebSocket proxy) rather than owning data of its 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, then set UPSTREAM_BASE_URL +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, HTTP + WS proxy wiring, Bun.serve +├── index.controller.ts # API router (mounts each feature) +├── config/ # app configuration (env-driven, incl. upstream.baseUrl) +├── factory/web.factory.ts # Variables + WrapRegistry augmentation +├── gateway/ws-proxy.ts # WebSocket proxy helper — READ THE FILE HEADER +├── helpers/ # app-owned helpers (roles, ...) +├── middleware/auth.ts # auth stack, available for management/admin routes +└── features/ + └── proxy/web/proxy.controller.ts # HTTP reverse-proxy example (hono/proxy) +tests/ # bun:test suites (no live network calls) +``` + +## HTTP proxying + +`src/features/proxy/web/proxy.controller.ts` forwards `GET /proxy/*` to `appConfig.upstream.baseUrl` using Hono's built-in `proxy()` helper (`hono/proxy` — the standard approach, not a hand-rolled one). Add more methods/routes the same way; strip or forward headers as your upstream needs. + +## WebSocket proxying — read this before relying on it + +`src/gateway/ws-proxy.ts` is explicitly a **best-effort starting point**, documented as such in its file header: it relays frames bidirectionally and closes one side when the other closes, but has no backpressure handling, no upstream reconnection, and no built-in auth/rate-limiting on the upgrade itself. It's wired up in `src/index.ts` at `GET /ws-proxy/*`, defaulting to the same upstream as the HTTP proxy with its scheme swapped to `ws`/`wss`. Harden it (or use a purpose-built WS proxy) before depending on it in production. + +## Growing into a database + +If this project later needs to persist data of its own, 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`. + +## Useful commands + +| Command | Description | +| --- | --- | +| `bun run dev` | dev server with hot reload | +| `bun test` | test suite | +| `bun run typecheck` / `bun run lint` | static checks | diff --git a/packages/create-wrap/profiles/gateway/files/package.json b/packages/create-wrap/profiles/gateway/files/package.json new file mode 100644 index 0000000..ef67681 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/package.json @@ -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" + } +} diff --git a/packages/create-wrap/profiles/gateway/files/src/bootstrap.ts b/packages/create-wrap/profiles/gateway/files/src/bootstrap.ts new file mode 100644 index 0000000..ab9f5ab --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/bootstrap.ts @@ -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()); diff --git a/packages/create-wrap/profiles/gateway/files/src/config/app.config.ts b/packages/create-wrap/profiles/gateway/files/src/config/app.config.ts new file mode 100644 index 0000000..2e39b66 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/config/app.config.ts @@ -0,0 +1,108 @@ +import { logger } from "@donilite/wrap"; + +/** + * Application configuration — Gateway/proxy profile: no `database`, + * `storage` or `email` sections (a gateway owns no data of its own), plus + * an `upstream` section for what it fronts (HTTP proxy target; the + * WebSocket proxy helper in `src/gateway/ws-proxy.ts` takes its own + * target per-route instead, since a gateway commonly fronts more than one + * upstream for different paths). + */ +export interface AppConfig { + // Server + port: number; + host: string; + env: "development" | "production" | "test"; + + // HTTP proxy target — see src/features/proxy/web/proxy.controller.ts + upstream: { + baseUrl: string; + }; + + // JWT — only needed if you guard management/admin routes on the + // gateway itself; proxied routes don't require it. + 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", + + upstream: { + baseUrl: process.env.UPSTREAM_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", + }, +}; diff --git a/packages/create-wrap/profiles/gateway/files/src/factory/web.factory.ts b/packages/create-wrap/profiles/gateway/files/src/factory/web.factory.ts new file mode 100644 index 0000000..d3344ea --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/factory/web.factory.ts @@ -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` 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; +}>(); diff --git a/packages/create-wrap/profiles/gateway/files/src/features/proxy/web/proxy.controller.ts b/packages/create-wrap/profiles/gateway/files/src/features/proxy/web/proxy.controller.ts new file mode 100644 index 0000000..9dda3b4 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/features/proxy/web/proxy.controller.ts @@ -0,0 +1,41 @@ +import { Controller, Get, RouterController } from "@donilite/wrap"; +import type { Context } from "hono"; +import { proxy } from "hono/proxy"; +import { webFactory } from "@/factory/web.factory"; +import { appConfig } from "@/config/app.config"; + +/** + * HTTP proxy example, using Hono's built-in `proxy()` helper (the standard + * approach — see `hono/proxy` — rather than hand-rolling one). Forwards + * everything under `/proxy/*` to `appConfig.upstream.baseUrl`, stripping + * the `/proxy` prefix and a couple of hop-by-hop-ish headers that + * shouldn't be blindly forwarded. + * + * `RouterController`, no repository/entity — this profile is about + * fronting/relaying other services, not owning data. + */ +@Controller({ + basePath: "/proxy", + tags: ["Gateway"], + description: "HTTP reverse-proxy example", +}) +export class ProxyController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: "/*", description: "Proxy GET requests to the configured upstream" }) + async proxyGet(c: Context) { + const upstreamPath = c.req.path.replace(/^\/proxy/, ""); + return proxy(`${appConfig.upstream.baseUrl}${upstreamPath}`, { + headers: { + ...c.req.header(), + "X-Forwarded-For": c.req.header("x-forwarded-for") ?? "", + "X-Forwarded-Host": c.req.header("host"), + // Don't propagate this app's own auth to the upstream by default — + // opt back in per-route if the upstream expects it. + Authorization: undefined, + }, + }); + } +} diff --git a/packages/create-wrap/profiles/gateway/files/src/gateway/ws-proxy.ts b/packages/create-wrap/profiles/gateway/files/src/gateway/ws-proxy.ts new file mode 100644 index 0000000..4b26622 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/gateway/ws-proxy.ts @@ -0,0 +1,120 @@ +/** + * Best-effort WebSocket proxy helper — mounts a Hono route that upgrades + * the incoming connection (via `hono/bun`'s native WebSocket support, same + * primitive `@donilite/wrap/realtime` uses) and relays frames bidirectionally + * to/from an upstream WebSocket server using Bun's native `WebSocket` client. + * + * This is a STARTING POINT, not a production-hardened proxy. What it does + * cover: text/binary frame relay both directions, closing one side when the + * other closes, and surfacing upstream connection failures as a close code + * on the client side. What it deliberately does NOT cover (flagged here + * rather than silently left out): + * + * - Backpressure: `ws.send()` on either side is fire-and-forget; a slow + * consumer on one side isn't propagated as backpressure to the other + * (Bun/the browser both buffer, but there's no bound applied here). + * - Reconnection: if the upstream connection drops, the client connection + * is closed too — no automatic upstream reconnect/retry. + * - Per-message size/rate limits, auth on the upgrade itself (add your own + * check, mirroring `RealtimeOptions.authorize` in + * `@donilite/wrap/realtime`, before calling `wsProxy()`). + * - Subprotocol negotiation beyond passing `protocols` through as-is. + * + * For anything beyond a quick internal/trusted-network proxy, harden this + * (or reach for a purpose-built WS proxy) before relying on it in production. + */ +import { createBunWebSocket } from "hono/bun"; +import type { ServerWebSocket } from "bun"; +import type { MiddlewareHandler } from "hono"; +import type { WSContext } from "hono/ws"; +import { logger } from "@donilite/wrap"; + +const { upgradeWebSocket, websocket } = createBunWebSocket(); + +/** `Bun.serve({ websocket })` — combine with any other subprotocol's `websocket` handlers you use. */ +export { websocket as wsProxyWebSocketHandlers }; + +export interface WsProxyOptions { + /** Upstream WebSocket URL to relay to, e.g. `wss://upstream.example.com/socket`. */ + target: string | ((requestUrl: URL) => string); + /** Forwarded as the outgoing connection's subprotocols, if any. */ + protocols?: string[]; +} + +/** + * Build a Hono route handler that proxies a WebSocket connection. + * + * ```ts + * app.get("/ws-proxy/*", wsProxy({ target: "wss://upstream.example.com" })); + * const server = Bun.serve({ fetch: app.fetch, websocket: wsProxyWebSocketHandlers, port }); + * ``` + */ +export function wsProxy(options: WsProxyOptions): MiddlewareHandler { + return upgradeWebSocket((c) => { + const targetUrl = + typeof options.target === "function" + ? options.target(new URL(c.req.url)) + : options.target; + + let upstream: WebSocket | undefined; + // Frames arriving before the upstream socket is OPEN are queued, not + // dropped — the upstream connection is opened asynchronously. + const pending: (string | ArrayBuffer)[] = []; + // Set once onOpen fires — the upstream's event listeners (registered + // below) need a way to reach the client-side WSContext, which only + // exists once Hono's onOpen callback runs. + let clientWs: WSContext | undefined; + + try { + upstream = new WebSocket(targetUrl, options.protocols); + upstream.binaryType = "arraybuffer"; + + upstream.addEventListener("open", () => { + for (const frame of pending.splice(0)) { + upstream!.send(frame); + } + }); + + upstream.addEventListener("message", (event) => { + const data = event.data as string | ArrayBuffer; + clientWs?.send(data); + }); + + upstream.addEventListener("close", (event) => { + clientWs?.close(event.code, event.reason); + }); + + upstream.addEventListener("error", (error) => { + logger.warn("ws-proxy: upstream connection error", { targetUrl }, error); + clientWs?.close(1011, "upstream error"); + }); + } catch (error) { + logger.warn("ws-proxy: failed to open upstream connection", { targetUrl }, error); + upstream = undefined; + } + + return { + onOpen: (_event, ws) => { + clientWs = ws; + if (!upstream) { + ws.close(1011, "failed to reach upstream"); + } + }, + onMessage: (event) => { + const data = event.data as string | ArrayBuffer; + if (!upstream || upstream.readyState !== WebSocket.OPEN) { + pending.push(data); + return; + } + upstream.send(data); + }, + onClose: (event) => { + upstream?.close(event.code, event.reason); + }, + onError: () => { + logger.warn("ws-proxy: client connection error", { targetUrl }); + upstream?.close(); + }, + }; + }); +} diff --git a/packages/create-wrap/profiles/gateway/files/src/index.controller.ts b/packages/create-wrap/profiles/gateway/files/src/index.controller.ts new file mode 100644 index 0000000..423b4a9 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/index.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, RouterController } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { ProxyController } from "@/features/proxy/web/proxy.controller"; + +/** + * Root controller: owns health/root routes and composes feature + * controllers as children — the only controller registered on `Wrap` + * itself (see src/index.ts). Each child keeps its own + * `@Controller({ basePath })` as the single source of truth for where it + * lives; add new features here as `this.register(SomeController)`. + * + * The WebSocket proxy (src/gateway/ws-proxy.ts) is mounted directly on + * `Wrap` in src/index.ts instead of through a controller — it needs the + * raw Bun.serve `websocket` handler wiring, same as `@donilite/wrap/realtime`. + */ +@Controller({ + basePath: "/", + tags: ["Health"], + description: "Service health and root routes", +}) +export class IndexController extends RouterController { + constructor() { + super(webFactory.createApp()); + this.register(ProxyController); + } + + @Get({ path: "/", description: "Health check" }) + async health(c: Context) { + return c.json({ status: "ok" }); + } +} diff --git a/packages/create-wrap/profiles/gateway/files/src/index.ts b/packages/create-wrap/profiles/gateway/files/src/index.ts new file mode 100644 index 0000000..62d47b9 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/src/index.ts @@ -0,0 +1,52 @@ +import "reflect-metadata"; +import "./bootstrap"; // env — must stay first +import { Wrap } from "@donilite/wrap"; +import { IndexController } from "@/index.controller"; +import { auth } from "@/middleware/auth"; +import { appConfig } from "@/config/app.config"; +import { wsProxy, wsProxyWebSocketHandlers } from "@/gateway/ws-proxy"; + +const app = new Wrap({ + cors: { + origin: appConfig.cors.origin, + credentials: appConfig.cors.credentials, + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + }, +}); + +// Auth: made available to `.swagger()` for the generated spec's security +// schemes; guards apply per route group, same as any other middleware. +app.with(auth); + +// Controllers self-register at their own @Controller basePath. +app.register(IndexController); + +if (appConfig.swagger.enabled) { + app.swagger({ + path: appConfig.swagger.path, + title: appConfig.swagger.title, + version: appConfig.swagger.version, + description: "API documentation with enhanced features", + servers: [ + { + url: `http://${appConfig.host === "0.0.0.0" ? "localhost" : appConfig.host}:${appConfig.port}`, + description: (process.env.NODE_ENV || "development").toUpperCase(), + }, + ], + }); +} + +// WebSocket proxy — best-effort helper, see src/gateway/ws-proxy.ts for +// what it does and doesn't cover before relying on this. Defaults to the +// same upstream as the HTTP proxy with its scheme swapped to ws(s); point +// it at a different upstream by passing your own `target`. +app.get( + "/ws-proxy/*", + wsProxy({ target: appConfig.upstream.baseUrl.replace(/^http/, "ws") }), +); + +const server = app.listen(appConfig.port, appConfig.host, { + websocket: wsProxyWebSocketHandlers, +}); + +console.log(`⚡ ready on http://localhost:${server.port}`); diff --git a/packages/create-wrap/profiles/gateway/files/tests/swagger.test.ts b/packages/create-wrap/profiles/gateway/files/tests/swagger.test.ts new file mode 100644 index 0000000..50034f5 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/tests/swagger.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "bun:test"; +import { SwaggerGenerator, Wrap } from "@donilite/wrap"; +// Side-effect import: registers @Controller/@Route metadata used below. +import "@/index.controller"; +import { IndexController } from "@/index.controller"; + +// Registering on a Wrap is what makes resolveControllerPath() have +// anything recorded to walk (see @donilite/wrap's swagger generator). +new Wrap().register(IndexController); + +describe("SwaggerGenerator (gateway profile, no DB)", () => { + it("documents the proxy route without needing a database", () => { + const generator = new SwaggerGenerator({ title: "Test", version: "0.0.0" }); + const spec = generator.generateSpec(); + + expect(spec.paths["/proxy/*"]?.get).toBeDefined(); + expect(spec.tags.some((t: { name: string }) => t.name === "Gateway")).toBe(true); + }); +}); diff --git a/packages/create-wrap/profiles/gateway/files/tests/wrap.test.ts b/packages/create-wrap/profiles/gateway/files/tests/wrap.test.ts new file mode 100644 index 0000000..d22ffc5 --- /dev/null +++ b/packages/create-wrap/profiles/gateway/files/tests/wrap.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "bun:test"; +import { requestJson } from "@donilite/wrap/testing"; +import { Controller, Get, JwtCookieAuthController, RouterController, Wrap } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { IndexController } from "@/index.controller"; + +// Exercises the actual production wiring (Wrap -> IndexController -> +// ProxyController as a child), not a direct `new ProxyController()` +// bypass — this is what src/index.ts really boots. No database anywhere: +// this profile fronts other services rather than owning data. +// +// NOTE: the live upstream call ProxyController.proxyGet() makes (via +// hono/proxy) is NOT exercised here — that would be a real network call in +// the test suite. Route wiring (this controller is reachable, health check +// works) is what's covered; verify the actual proxying manually or with a +// local stub server if you extend this. +describe("Wrap composition root (gateway profile, no DB)", () => { + it("serves the health route registered on IndexController", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); + + it("returns the standard 404 shape for unknown routes", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/nope"); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); +}); + +@Controller({ basePath: "/occupants-like-child" }) +class OrderTestChildController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: "/" }) + async list(c: Context) { + return c.json({ matched: "child" }); + } +} + +@Controller({ basePath: "/parent-with-id" }) +class OrderTestParentController extends RouterController { + constructor() { + super(webFactory.createApp()); + // Mirrors a real bug: a parent whose own route has a :param segment, + // composing a child at a static prefix. Hono's router is purely + // registration-order-dependent for overlapping patterns — if the + // parent's own :id route were registered before the child is mounted, + // it would swallow every request meant for the child (":id" matches + // the literal string "occupants-like-child" too). + this.register(OrderTestChildController); + } + + @Get({ path: "/:id" }) + async byId(c: Context) { + return c.json({ matched: "parent", id: c.req.param("id") }); + } +} + +describe("parent → children registration order", () => { + it("a child's static-prefix route is not swallowed by the parent's own :param route", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const childRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/occupants-like-child", + ); + expect(childRes.status).toBe(200); + expect(childRes.body).toEqual({ matched: "child" }); + }); + + it("the parent's own :param route still matches a genuine id", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const parentRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/some-real-id", + ); + expect(parentRes.status).toBe(200); + expect(parentRes.body).toEqual({ matched: "parent", id: "some-real-id" }); + }); +}); + +describe("JwtCookieAuthController", () => { + it("exposes an OpenAPI security scheme", () => { + const auth = new JwtCookieAuthController({ secret: "wrap-test" }); + const schemes = auth.openApiSecurityScheme(); + expect(Object.keys(schemes)).toEqual(["bearerAuth", "cookieAuth"]); + }); +}); diff --git a/packages/create-wrap/profiles/gateway/remove.txt b/packages/create-wrap/profiles/gateway/remove.txt new file mode 100644 index 0000000..a113c0a --- /dev/null +++ b/packages/create-wrap/profiles/gateway/remove.txt @@ -0,0 +1,17 @@ +# Paths (relative to the scaffolded project root) deleted after the base +# template is copied, before this profile's files/ overlay is applied. +# Blank lines and lines starting with # are ignored. + +drizzle.config.ts +compose.yml +src/db +src/features/example +tests/cache.test.ts +tests/example.controller.test.ts +tests/example.repository.sync.test.ts +tests/example.repository.test.ts +tests/example.service.test.ts +tests/realtime.test.ts +tests/swagger.test.ts +tests/transactions.test.ts +tests/wrap.test.ts diff --git a/packages/create-wrap/profiles/lightweight-api/files/.env.example b/packages/create-wrap/profiles/lightweight-api/files/.env.example new file mode 100644 index 0000000..e1ba4fc --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/.env.example @@ -0,0 +1,15 @@ +JWT_SECRET="your_jwt_secret_key_here" + +# 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 diff --git a/packages/create-wrap/profiles/lightweight-api/files/README.md b/packages/create-wrap/profiles/lightweight-api/files/README.md new file mode 100644 index 0000000..e40eae6 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/README.md @@ -0,0 +1,56 @@ +# {{APP_NAME}} + +Auth-only / lightweight API built on [@donilite/wrap](https://github.com/DoniLite/wrap#readme) — Hono + Bun, decorator-driven. **No database** — this profile is for apps that just need routes, auth and OpenAPI docs (a BFF, an internal tool, a service that owns no data of its 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) +├── factory/web.factory.ts # Variables + WrapRegistry augmentation +├── helpers/ # app-owned helpers (roles, ...) +├── middleware/auth.ts # auth stack built from config +└── features/ + └── greeting/ # a vertical slice with NO repository/entity + ├── DTO/ # zod-backed DTOs (SchemaDTO, not entity-derived) + ├── services/ # WrapService subclass (no repository attached) + └── web/ # RouterController subclass (@Get, @Post, ...) +tests/ # bun:test suites +``` + +## Creating a feature + +A DB-free feature has three pieces instead of five (no entity, no repository): + +1. **DTO** — `SchemaDTO(z.object({ ... }))`, see `src/features/greeting/DTO/greeting.dto.ts`. +2. **Service** — extend `WrapService` (not `BaseService`), see `src/features/greeting/services/greeting.service.ts`. Inject other services, call external APIs, whatever the feature needs. +3. **Controller** — extend `RouterController` (not `BaseController`), see `src/features/greeting/web/greeting.controller.ts`. 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: decorate the service class with `@Service()` (so `ServiceFactory.getService()` singleton-caches it, same as `BaseController` does for entity-backed services) and decorate any method that takes user-supplied input with `@ValidateDTO()` — it validates the request body against the DTO's zod schema and replaces the argument with the parsed instance before your method body runs. `GreetingService.greet()` does exactly this; follow the same shape for your own DB-free services. + +Guard a route with `auth.authMiddleware` (see `@UseMiddleware([auth.authMiddleware])` on `greetPrivate`); role-based access is `auth.requireRoles([...])`. + +## Growing into a database + +If this project later needs Postgres, 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`/`BaseController`. + +## Useful commands + +| Command | Description | +| --- | --- | +| `bun run dev` | dev server with hot reload | +| `bun test` | test suite | +| `bun run typecheck` / `bun run lint` | static checks | diff --git a/packages/create-wrap/profiles/lightweight-api/files/package.json b/packages/create-wrap/profiles/lightweight-api/files/package.json new file mode 100644 index 0000000..ef67681 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/package.json @@ -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" + } +} diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/bootstrap.ts b/packages/create-wrap/profiles/lightweight-api/files/src/bootstrap.ts new file mode 100644 index 0000000..ab9f5ab --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/bootstrap.ts @@ -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()); diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/config/app.config.ts b/packages/create-wrap/profiles/lightweight-api/files/src/config/app.config.ts new file mode 100644 index 0000000..23edc2b --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/config/app.config.ts @@ -0,0 +1,96 @@ +import { logger } from "@donilite/wrap"; + +/** + * Application configuration — Lightweight-API profile: no `database`, + * `storage` or `email` sections, since this profile has no DB and isn't + * assumed to need file storage or outbound mail. Add them back (see the + * full-backend profile's `app.config.ts`) if the project grows into them. + */ +export interface AppConfig { + // Server + port: number; + host: string; + env: "development" | "production" | "test"; + + // 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", + + 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", + }, +}; diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/factory/web.factory.ts b/packages/create-wrap/profiles/lightweight-api/files/src/factory/web.factory.ts new file mode 100644 index 0000000..d3344ea --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/factory/web.factory.ts @@ -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` 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; +}>(); diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/DTO/greeting.dto.ts b/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/DTO/greeting.dto.ts new file mode 100644 index 0000000..77eeeb5 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/DTO/greeting.dto.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; +import { DTO, SchemaDTO } from "@donilite/wrap"; + +/** + * This profile has no entity/repository — `SchemaDTO()` builds a DTO + * straight from a zod schema (validation + OpenAPI, same as an + * entity-derived one) for features that aren't table-backed. + */ +@DTO() +export class GreetingRequestDTO extends SchemaDTO( + z.object({ + name: z.string().min(1).max(80), + }), +) {} + +@DTO() +export class GreetingResponseDTO extends SchemaDTO( + z.object({ + message: z.string(), + }), +) {} diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/services/greeting.service.ts b/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/services/greeting.service.ts new file mode 100644 index 0000000..c7b102f --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/services/greeting.service.ts @@ -0,0 +1,34 @@ +import { Service, ValidateDTO, WrapService } from "@donilite/wrap"; +import type { Context } from "hono"; +import type { GreetingRequestDTO, GreetingResponseDTO } from "../DTO/greeting.dto"; + +/** + * `WrapService` (not `BaseService`) — no repository/entity attached. + * This is the base for orchestration, aggregation or any feature that + * isn't table-CRUD: reach out to another service, call an external API, + * compose other services, etc. See `RouterController`/`BaseController` in + * `web/greeting.controller.ts` for the matching controller-side pattern. + * + * Same conventions as an entity-backed `BaseService`, even without a + * repository: `@Service()` on the class (registers it with + * `ServiceFactory`/`SERVICE_CLASSES`, same singleton lookup + * `BaseController`-style controllers use), and `@ValidateDTO()` on any + * method that takes user-supplied input — it validates the request body + * against the DTO's zod schema and replaces the argument with the parsed + * instance, exactly like `BaseService.create()` already does for + * entity-backed services. Both decorators are fully generic in + * `@donilite/wrap` (no dependency on `BaseService`/a repository) — this + * isn't a special DB-free variant, it's the same paradigm. + */ +@Service() +export class GreetingService extends WrapService { + @ValidateDTO() + async greet( + dto: GreetingRequestDTO, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _c: Context, + ): Promise { + this.logger.debug("Building greeting", { name: dto.name }); + return { message: `Hello, ${dto.name}!` } as GreetingResponseDTO; + } +} diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/web/greeting.controller.ts b/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/web/greeting.controller.ts new file mode 100644 index 0000000..963b026 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/web/greeting.controller.ts @@ -0,0 +1,75 @@ +import { + ApiResponse, + Controller, + Post, + RouterController, + ServiceFactory, + Serialize, + UseMiddleware, +} from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { auth } from "@/middleware/auth"; +import { GreetingRequestDTO, GreetingResponseDTO } from "../DTO/greeting.dto"; +import { GreetingService } from "../services/greeting.service"; + +/** + * `RouterController` (not `BaseController`) — no repository, no + * entity, just a plain Hono-mounted controller backed by a service. + * Demonstrates the auth-only / lightweight-API shape: routes, DTOs, + * OpenAPI docs and auth guards all work without a database — and the + * controller-side half of the `@Service()`/`@ValidateDTO()` convention + * (`ServiceFactory.getService()`, body -> `DTO.from()` -> service call) + * is identical to an entity-backed `BaseController`, see + * `src/features/example/web/example.controller.ts` in the full-backend + * profile for the same shape with a repository behind it. + */ +@Controller({ + basePath: "/greeting", + tags: ["Greeting"], + description: "Example auth-guarded, DB-free feature", +}) +export class GreetingController extends RouterController { + private readonly service = ServiceFactory.getService(GreetingService); + + constructor() { + super(webFactory.createApp()); + } + + @Post({ + path: "/", + description: "Greet a name — public route", + body: GreetingRequestDTO, + }) + @ApiResponse(200, { description: "Success", schema: GreetingResponseDTO }) + @Serialize(GreetingResponseDTO) + async greet(c: Context) { + try { + const body = await c.req.json(); + const dto = GreetingRequestDTO.from(body); + const result = await this.service.greet(dto, c); + return c.json(result); + } catch (e) { + return this.handleError(c, e); + } + } + + @Post({ + path: "/private", + description: "Greet a name — requires authentication", + body: GreetingRequestDTO, + }) + @ApiResponse(200, { description: "Success", schema: GreetingResponseDTO }) + @UseMiddleware([auth.authMiddleware]) + @Serialize(GreetingResponseDTO) + async greetPrivate(c: Context) { + try { + const body = await c.req.json(); + const dto = GreetingRequestDTO.from(body); + const result = await this.service.greet(dto, c); + return c.json(result); + } catch (e) { + return this.handleError(c, e); + } + } +} diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/index.controller.ts b/packages/create-wrap/profiles/lightweight-api/files/src/index.controller.ts new file mode 100644 index 0000000..d8c6ce1 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/index.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, RouterController } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { GreetingController } from "@/features/greeting/web/greeting.controller"; + +/** + * Root controller: owns health/root routes and composes feature + * controllers as children — the only controller registered on `Wrap` + * itself (see src/index.ts). Each child keeps its own + * `@Controller({ basePath })` as the single source of truth for where it + * lives; add new features here as `this.register(SomeController)`. + */ +@Controller({ + basePath: "/", + tags: ["Health"], + description: "Service health and root routes", +}) +export class IndexController extends RouterController { + constructor() { + super(webFactory.createApp()); + this.register(GreetingController); + } + + @Get({ path: "/", description: "Health check" }) + async health(c: Context) { + return c.json({ status: "ok" }); + } +} diff --git a/packages/create-wrap/profiles/lightweight-api/files/src/index.ts b/packages/create-wrap/profiles/lightweight-api/files/src/index.ts new file mode 100644 index 0000000..bc698ff --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/src/index.ts @@ -0,0 +1,40 @@ +import "reflect-metadata"; +import "./bootstrap"; // env — must stay first +import { Wrap } from "@donilite/wrap"; +import { IndexController } from "@/index.controller"; +import { auth } from "@/middleware/auth"; +import { appConfig } from "@/config/app.config"; + +const app = new Wrap({ + cors: { + origin: appConfig.cors.origin, + credentials: appConfig.cors.credentials, + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + }, +}); + +// Auth: made available to `.swagger()` for the generated spec's security +// schemes; guards apply per route group, same as any other middleware. +app.with(auth); + +// Controllers self-register at their own @Controller basePath. +app.register(IndexController); + +if (appConfig.swagger.enabled) { + app.swagger({ + path: appConfig.swagger.path, + title: appConfig.swagger.title, + version: appConfig.swagger.version, + description: "API documentation with enhanced features", + servers: [ + { + url: `http://${appConfig.host === "0.0.0.0" ? "localhost" : appConfig.host}:${appConfig.port}`, + description: (process.env.NODE_ENV || "development").toUpperCase(), + }, + ], + }); +} + +const server = app.listen(appConfig.port, appConfig.host); + +console.log(`⚡ ready on http://localhost:${server.port}`); diff --git a/packages/create-wrap/profiles/lightweight-api/files/tests/swagger.test.ts b/packages/create-wrap/profiles/lightweight-api/files/tests/swagger.test.ts new file mode 100644 index 0000000..43752e9 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/tests/swagger.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "bun:test"; +import { JwtCookieAuthController, SwaggerGenerator, Wrap } from "@donilite/wrap"; +// Side-effect import: registers @Controller/@Route metadata used below. +import "@/index.controller"; +import { IndexController } from "@/index.controller"; + +// Registering on a Wrap is what makes resolveControllerPath() have +// anything recorded to walk (see @donilite/wrap's swagger generator). +new Wrap().register(IndexController); + +describe("SwaggerGenerator (lightweight-api profile, no DB)", () => { + it("documents the greeting routes without needing a database", () => { + const generator = new SwaggerGenerator({ title: "Test", version: "0.0.0" }); + const spec = generator.generateSpec(); + + expect(spec.paths["/greeting"]?.post).toBeDefined(); + }); + + it("marks the private greeting route as requiring auth", () => { + const auth = new JwtCookieAuthController({ secret: "swagger-test" }); + const generator = new SwaggerGenerator({ title: "Test", version: "0.0.0" }, auth); + const spec = generator.generateSpec(); + + const guarded = spec.paths["/greeting/private"]?.post; + expect(guarded.security).toBeDefined(); + expect(guarded.security.length).toBeGreaterThan(0); + expect(guarded.responses["401"]).toBeDefined(); + + const publicRoute = spec.paths["/greeting"]?.post; + expect(publicRoute.security).toBeUndefined(); + }); +}); diff --git a/packages/create-wrap/profiles/lightweight-api/files/tests/wrap.test.ts b/packages/create-wrap/profiles/lightweight-api/files/tests/wrap.test.ts new file mode 100644 index 0000000..8d0b233 --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/files/tests/wrap.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "bun:test"; +import { requestJson } from "@donilite/wrap/testing"; +import { Controller, Get, JwtCookieAuthController, RouterController, Wrap } from "@donilite/wrap"; +import type { Context } from "hono"; +import { webFactory } from "@/factory/web.factory"; +import { IndexController } from "@/index.controller"; + +// Exercises the actual production wiring (Wrap -> IndexController -> +// GreetingController as a child), not a direct `new GreetingController()` +// bypass — this is what src/index.ts really boots. No database anywhere: +// this profile is auth-only / DB-free by design. +describe("Wrap composition root (lightweight-api profile, no DB)", () => { + it("serves the health route registered on IndexController", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: "ok" }); + }); + + it("serves GreetingController as a child registered by IndexController, DTO validated by @ValidateDTO()", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "POST", "/greeting", { name: "World" }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ message: "Hello, World!" }); + }); + + it("rejects an invalid body — @ValidateDTO() on GreetingService.greet() runs even without a repository", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "POST", "/greeting", { name: "" }); + expect(res.status).toBe(400); + }); + + it("guards the private greeting route with AuthController.authMiddleware", async () => { + const app = new Wrap(); + app.register(IndexController); + + const anonymous = await requestJson(app.raw, "POST", "/greeting/private", { name: "World" }); + expect(anonymous.status).toBe(401); + }); + + it("returns the standard 404 shape for unknown routes", async () => { + const app = new Wrap(); + app.register(IndexController); + + const res = await requestJson(app.raw, "GET", "/nope"); + expect(res.status).toBe(404); + expect(res.body.success).toBe(false); + }); +}); + +@Controller({ basePath: "/occupants-like-child" }) +class OrderTestChildController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: "/" }) + async list(c: Context) { + return c.json({ matched: "child" }); + } +} + +@Controller({ basePath: "/parent-with-id" }) +class OrderTestParentController extends RouterController { + constructor() { + super(webFactory.createApp()); + // Mirrors a real bug: a parent whose own route has a :param segment, + // composing a child at a static prefix. Hono's router is purely + // registration-order-dependent for overlapping patterns — if the + // parent's own :id route were registered before the child is mounted, + // it would swallow every request meant for the child (":id" matches + // the literal string "occupants-like-child" too). + this.register(OrderTestChildController); + } + + @Get({ path: "/:id" }) + async byId(c: Context) { + return c.json({ matched: "parent", id: c.req.param("id") }); + } +} + +describe("parent → children registration order", () => { + it("a child's static-prefix route is not swallowed by the parent's own :param route", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const childRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/occupants-like-child", + ); + expect(childRes.status).toBe(200); + expect(childRes.body).toEqual({ matched: "child" }); + }); + + it("the parent's own :param route still matches a genuine id", async () => { + const app = new Wrap(); + app.register(OrderTestParentController); + + const parentRes = await requestJson( + app.raw, + "GET", + "/parent-with-id/some-real-id", + ); + expect(parentRes.status).toBe(200); + expect(parentRes.body).toEqual({ matched: "parent", id: "some-real-id" }); + }); +}); + +describe("JwtCookieAuthController", () => { + it("exposes an OpenAPI security scheme", () => { + const auth = new JwtCookieAuthController({ secret: "wrap-test" }); + const schemes = auth.openApiSecurityScheme(); + expect(Object.keys(schemes)).toEqual(["bearerAuth", "cookieAuth"]); + }); +}); diff --git a/packages/create-wrap/profiles/lightweight-api/remove.txt b/packages/create-wrap/profiles/lightweight-api/remove.txt new file mode 100644 index 0000000..a113c0a --- /dev/null +++ b/packages/create-wrap/profiles/lightweight-api/remove.txt @@ -0,0 +1,17 @@ +# Paths (relative to the scaffolded project root) deleted after the base +# template is copied, before this profile's files/ overlay is applied. +# Blank lines and lines starting with # are ignored. + +drizzle.config.ts +compose.yml +src/db +src/features/example +tests/cache.test.ts +tests/example.controller.test.ts +tests/example.repository.sync.test.ts +tests/example.repository.test.ts +tests/example.service.test.ts +tests/realtime.test.ts +tests/swagger.test.ts +tests/transactions.test.ts +tests/wrap.test.ts diff --git a/packages/create-wrap/src/index.ts b/packages/create-wrap/src/index.ts index 8123d4b..d5b1fb0 100644 --- a/packages/create-wrap/src/index.ts +++ b/packages/create-wrap/src/index.ts @@ -1,16 +1,10 @@ #!/usr/bin/env bun -import { - existsSync, - mkdirSync, - cpSync, - readFileSync, - writeFileSync, - readdirSync, - statSync, -} from "fs"; -import { join, basename, dirname } from "path"; - -const TEMPLATE_DIR = join(import.meta.dir, "..", "template"); +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { text } from "./prompts"; +import { promptForAnswers, PROFILES } from "./profiles"; +import { scaffoldProject, type Config } from "./scaffold"; +import { log, toSnakeCase } from "./utils"; /** * Version range of @donilite/wrap injected into scaffolded projects — @@ -23,170 +17,38 @@ const WRAP_VERSION = `^${ ).version }`; -/** Entries never copied into a scaffolded project. */ -const COPY_IGNORE = new Set([ - "node_modules", - "bun.lock", - ".env", - "drizzle", - "dist", -]); - -interface Config { - projectName: string; - dbName: string; - targetDir: string; -} - -function log( - message: string, - type: "info" | "success" | "error" | "warn" = "info", -) { - const colors = { - info: "\x1b[36m", - success: "\x1b[32m", - error: "\x1b[31m", - warn: "\x1b[33m", - }; - const reset = "\x1b[0m"; - const icons = { - info: "ℹ", - success: "✔", - error: "✖", - warn: "⚠", - }; - console.log(`${colors[type]}${icons[type]} ${message}${reset}`); -} - -function toSnakeCase(str: string): string { - return str - .replace(/([a-z])([A-Z])/g, "$1_$2") - .replace(/[\s-]+/g, "_") - .toLowerCase(); -} - -function toPascalCase(str: string): string { - return str - .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")) - .replace(/^(.)/, (c) => c.toUpperCase()); -} - -function replacePlaceholders(content: string, config: Config): string { - return content - .replace(/\{\{APP_NAME\}\}/g, config.projectName) - .replace(/\{\{APP_NAME_SNAKE\}\}/g, toSnakeCase(config.projectName)) - .replace(/\{\{APP_NAME_PASCAL\}\}/g, toPascalCase(config.projectName)) - .replace(/\{\{DB_NAME\}\}/g, config.dbName); -} - -function copyTemplateFiles(srcDir: string, destDir: string, config: Config) { - if (!existsSync(destDir)) { - mkdirSync(destDir, { recursive: true }); - } - - const entries = readdirSync(srcDir); - - for (const entry of entries) { - if (COPY_IGNORE.has(entry)) continue; - const srcPath = join(srcDir, entry); - const destPath = join(destDir, entry); - const stat = statSync(srcPath); - - if (stat.isDirectory()) { - copyTemplateFiles(srcPath, destPath, config); - } else { - // Read and replace placeholders in text files - const ext = entry.split(".").pop() || ""; - const textExtensions = [ - "ts", - "json", - "md", - "yml", - "yaml", - "env", - "example", - "gitignore", - ]; - - if (textExtensions.includes(ext) || entry.startsWith(".")) { - const content = readFileSync(srcPath, "utf-8"); - const replaced = replacePlaceholders(content, config); - writeFileSync(destPath, replaced); - } else { - // Binary files - just copy - cpSync(srcPath, destPath); - } - } - } -} - async function main() { - console.log("\n🚀 \x1b[1m\x1b[35mLite Backend Template\x1b[0m\n"); + console.log("\n🚀 \x1b[1m\x1b[35m@donilite/create-wrap\x1b[0m\n"); - // Get project name from args or prompt + // Project name: CLI arg or prompt. let projectName = process.argv[2]; - if (!projectName) { - process.stdout.write("Project name: "); - for await (const line of console) { - projectName = line.trim(); - break; - } + projectName = await text("Project name"); } - if (!projectName) { log("Project name is required", "error"); process.exit(1); } const targetDir = join(process.cwd(), projectName); - if (existsSync(targetDir)) { log(`Directory "${projectName}" already exists`, "error"); process.exit(1); } - const dbName = `${toSnakeCase(projectName)}_db`; + const answers = await promptForAnswers(); + const profileLabel = PROFILES.find((p) => p.id === answers.profile)?.label ?? answers.profile; const config: Config = { projectName, - dbName, + dbName: `${toSnakeCase(projectName)}_db`, targetDir, }; - log(`Creating project "${projectName}"...`, "info"); - - // Copy template files - copyTemplateFiles(TEMPLATE_DIR, targetDir, config); + log(`Creating "${projectName}" (${profileLabel})...`, "info"); + scaffoldProject(config, answers, WRAP_VERSION); + log("Project files generated", "success"); - // Patch package.json: project name + real @donilite/wrap version - // (the template uses a workspace link inside the monorepo) - const pkgPath = join(targetDir, "package.json"); - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); - pkg.name = toSnakeCase(projectName); - if (pkg.dependencies?.["@donilite/wrap"]) { - pkg.dependencies["@donilite/wrap"] = WRAP_VERSION; - } - writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); - - // Rename .gitignore.template to .gitignore (npm ignores .gitignore files) - const gitignoreTemplate = join(targetDir, "gitignore.template"); - const gitignore = join(targetDir, ".gitignore"); - if (existsSync(gitignoreTemplate)) { - cpSync(gitignoreTemplate, gitignore); - Bun.spawnSync(["rm", gitignoreTemplate]); - } - - // Rename .env.example if needed - const envExample = join(targetDir, ".env.example"); - const envFile = join(targetDir, ".env"); - if (existsSync(envExample) && !existsSync(envFile)) { - cpSync(envExample, envFile); - } - - log("Template files copied", "success"); - - // Install dependencies log("Installing dependencies...", "info"); const installResult = Bun.spawnSync(["bun", "install"], { cwd: targetDir, @@ -203,9 +65,12 @@ async function main() { console.log("\n\x1b[1m\x1b[32m✨ Project created successfully!\x1b[0m\n"); console.log("Next steps:\n"); console.log(` \x1b[36mcd ${projectName}\x1b[0m`); - console.log(" \x1b[36mbun run wake:db\x1b[0m # Start PostgreSQL"); - console.log(" \x1b[36mbun run push:db\x1b[0m # Push schema to database"); - console.log(" \x1b[36mbun run dev\x1b[0m # Start dev server"); + console.log(" \x1b[36mbun run init:env\x1b[0m # copy .env.example -> .env"); + if (answers.profile === "full-backend") { + console.log(" \x1b[36mbun run wake:db\x1b[0m # start PostgreSQL" + (answers.enableRedisCache || answers.enableRealtime ? " + Redis" : "")); + console.log(" \x1b[36mbun run push:db\x1b[0m # apply the schema"); + } + console.log(" \x1b[36mbun run dev\x1b[0m # start the dev server"); console.log( "\n Then visit \x1b[35mhttp://localhost:5000/docs\x1b[0m for Swagger UI\n", ); diff --git a/packages/create-wrap/src/profiles.ts b/packages/create-wrap/src/profiles.ts new file mode 100644 index 0000000..ebc1e79 --- /dev/null +++ b/packages/create-wrap/src/profiles.ts @@ -0,0 +1,88 @@ +import { confirm, select } from "./prompts"; + +/** + * A project "profile" picks which shape of app gets scaffolded. Every + * profile shares one base (the "full-backend" template tree itself — + * `template/`): a non-full-backend profile is generated by copying that + * base, then deleting the files it doesn't need (`profiles//remove.txt`) + * and layering its own files on top (`profiles//files/`). See + * `packages/create-wrap/profiles/README.md` for the full mechanics. + * + * "full-backend" itself has no remove/files directory — it doesn't need + * one, it's not a diff from anything — but it does have two yes/no + * follow-ups (Redis cache, realtime websockets) applied as small text + * edits to the copied template rather than a whole alternate file set, + * since they're each a single conditional block. + */ +export type ProfileId = + | "full-backend" + | "lightweight-api" + | "api-aggregator" + | "fullstack-ssr" + | "gateway"; + +export interface ProfileDefinition { + id: ProfileId; + label: string; + hint: string; +} + +export const PROFILES: ProfileDefinition[] = [ + { + id: "full-backend", + label: "Full backend", + hint: "Postgres + Drizzle, Redis cache, realtime websockets, auth — everything", + }, + { + id: "lightweight-api", + label: "Lightweight API / auth-only", + hint: "no DB, no cache, no realtime — routes + auth + OpenAPI docs", + }, + { + id: "api-aggregator", + label: "External API aggregator", + hint: "no DB — services that call out to other APIs, fronted by controllers", + }, + { + id: "fullstack-ssr", + label: "Fullstack SSR", + hint: "Hono + TanStack Router + React, server-rendered (no DB by default)", + }, + { + id: "gateway", + label: "Proxy / gateway", + hint: "HTTP reverse-proxy (hono/proxy) + best-effort WebSocket proxy", + }, +]; + +export interface ProfileAnswers { + profile: ProfileId; + /** full-backend only. */ + enableRedisCache: boolean; + /** full-backend only. */ + enableRealtime: boolean; +} + +/** Runs the interactive profile + follow-up prompts. */ +export async function promptForAnswers(): Promise { + const profile = await select( + "What kind of project is this?", + PROFILES.map((p) => ({ value: p.id, label: p.label, hint: p.hint })), + ); + + let enableRedisCache = false; + let enableRealtime = false; + + if (profile === "full-backend") { + enableRedisCache = await confirm( + "Enable the Redis cache backend? (in-memory cache is always available either way)", + true, + ); + enableRealtime = await confirm( + "Enable realtime websockets (entity events fanned out over /realtime)?", + true, + ); + } + + return { profile, enableRedisCache, enableRealtime }; +} diff --git a/packages/create-wrap/src/prompts.ts b/packages/create-wrap/src/prompts.ts new file mode 100644 index 0000000..96d652e --- /dev/null +++ b/packages/create-wrap/src/prompts.ts @@ -0,0 +1,75 @@ +/** + * Minimal interactive-prompt helpers, hand-rolled on top of Bun's stdin + * async iterator instead of pulling in a prompts library (`@clack/prompts` + * et al.) — this CLI is a one-shot scaffolding tool invoked via `bunx`, so + * keeping it dependency-light matters more than fancy TUI widgets, and the + * original CLI already used the same `for await (const line of console)` + * pattern for its single prompt. If this ever grows real multi-step TUI + * needs (arrow-key navigation, spinners), swapping in `@clack/prompts` is a + * contained change — every call site here goes through the three functions + * below. + */ + +/** Reads one line from stdin, trimmed. Empty input returns "". */ +async function readLine(): Promise { + for await (const line of console) { + return line.trim(); + } + return ""; +} + +/** Free-text prompt with an optional default shown in brackets. */ +export async function text( + message: string, + options: { default?: string } = {}, +): Promise { + const suffix = options.default ? ` \x1b[2m(${options.default})\x1b[0m` : ""; + process.stdout.write(`\x1b[36m?\x1b[0m ${message}${suffix}: `); + const answer = await readLine(); + return answer || options.default || ""; +} + +export interface SelectOption { + value: T; + label: string; + hint?: string; +} + +/** + * Numbered-menu single select — types a number (1-based) rather than + * arrow-key navigation. Re-prompts on invalid input; accepts the default + * (option 1) on an empty line. + */ +export async function select( + message: string, + options: SelectOption[], +): Promise { + console.log(`\x1b[36m?\x1b[0m ${message}`); + options.forEach((opt, i) => { + const hint = opt.hint ? ` \x1b[2m— ${opt.hint}\x1b[0m` : ""; + console.log(` \x1b[33m${i + 1}\x1b[0m) ${opt.label}${hint}`); + }); + + while (true) { + process.stdout.write(`Choose 1-${options.length} \x1b[2m(1)\x1b[0m: `); + const answer = await readLine(); + if (!answer) return options[0]!.value; + const index = Number.parseInt(answer, 10) - 1; + if (Number.isInteger(index) && index >= 0 && index < options.length) { + return options[index]!.value; + } + console.log("\x1b[31mInvalid choice, try again.\x1b[0m"); + } +} + +/** Yes/no confirm, defaulting to `defaultValue` on an empty line. */ +export async function confirm( + message: string, + defaultValue = true, +): Promise { + const hint = defaultValue ? "Y/n" : "y/N"; + process.stdout.write(`\x1b[36m?\x1b[0m ${message} \x1b[2m(${hint})\x1b[0m: `); + const answer = (await readLine()).toLowerCase(); + if (!answer) return defaultValue; + return answer === "y" || answer === "yes"; +} diff --git a/packages/create-wrap/src/scaffold.ts b/packages/create-wrap/src/scaffold.ts new file mode 100644 index 0000000..cfaad5f --- /dev/null +++ b/packages/create-wrap/src/scaffold.ts @@ -0,0 +1,262 @@ +import { + existsSync, + mkdirSync, + cpSync, + readFileSync, + writeFileSync, + readdirSync, + statSync, + rmSync, +} from "fs"; +import { join } from "path"; +import type { ProfileAnswers, ProfileId } from "./profiles"; +import { toPascalCase, toSnakeCase } from "./utils"; + +const TEMPLATE_DIR = join(import.meta.dir, "..", "template"); +const PROFILES_DIR = join(import.meta.dir, "..", "profiles"); + +/** Entries never copied into a scaffolded project. */ +const COPY_IGNORE = new Set([ + "node_modules", + "bun.lock", + ".env", + "drizzle", + "dist", +]); + +/** Extensions whose content gets `{{PLACEHOLDER}}` substitution. */ +const TEXT_EXTENSIONS = new Set([ + "ts", + "tsx", + "jsx", + "json", + "md", + "yml", + "yaml", + "env", + "example", + "gitignore", +]); + +export interface Config { + projectName: string; + dbName: string; + targetDir: string; +} + +function replacePlaceholders(content: string, config: Config): string { + return content + .replace(/\{\{APP_NAME\}\}/g, config.projectName) + .replace(/\{\{APP_NAME_SNAKE\}\}/g, toSnakeCase(config.projectName)) + .replace(/\{\{APP_NAME_PASCAL\}\}/g, toPascalCase(config.projectName)) + .replace(/\{\{DB_NAME\}\}/g, config.dbName); +} + +/** + * Recursively copy a directory tree, substituting `{{PLACEHOLDER}}`s in + * text files and applying `COPY_IGNORE`. Used both for the base template + * and for a profile's `files/` overlay (which reuses the exact same rules + * — same as the base template, an overlay file with `.ts`/`.json`/etc. + * gets placeholder substitution too). + */ +function copyTree(srcDir: string, destDir: string, config: Config) { + if (!existsSync(destDir)) { + mkdirSync(destDir, { recursive: true }); + } + + for (const entry of readdirSync(srcDir)) { + if (COPY_IGNORE.has(entry)) continue; + const srcPath = join(srcDir, entry); + const destPath = join(destDir, entry); + const stat = statSync(srcPath); + + if (stat.isDirectory()) { + copyTree(srcPath, destPath, config); + } else { + const ext = entry.split(".").pop() || ""; + if (TEXT_EXTENSIONS.has(ext) || entry.startsWith(".")) { + const content = readFileSync(srcPath, "utf-8"); + writeFileSync(destPath, replacePlaceholders(content, config)); + } else { + cpSync(srcPath, destPath); + } + } + } +} + +/** Delete a list of project-relative paths (files or directories), if present. */ +function removePaths(targetDir: string, relativePaths: string[]) { + for (const rel of relativePaths) { + const abs = join(targetDir, rel); + if (existsSync(abs)) { + rmSync(abs, { recursive: true, force: true }); + } + } +} + +/** Parse a `remove.txt` (one relative path per line, `#` comments, blank lines ignored). */ +function parseRemoveList(path: string): string[] { + if (!existsSync(path)) return []; + return readFileSync(path, "utf-8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} + +/** + * Non-full-backend profiles are generated as a diff against the base + * template: delete what the profile doesn't need, then layer the + * profile's own files on top (both listed in `packages/create-wrap/profiles//`). + */ +function applyProfileOverlay(profileId: ProfileId, targetDir: string, config: Config) { + const profileDir = join(PROFILES_DIR, profileId); + const removeListPath = join(profileDir, "remove.txt"); + const filesDir = join(profileDir, "files"); + + removePaths(targetDir, parseRemoveList(removeListPath)); + + if (existsSync(filesDir)) { + copyTree(filesDir, targetDir, config); + } +} + +/** Remove a contiguous block of lines between (and including) two markers, if both are found. */ +function stripBlock(content: string, startMarker: string, endMarker: string): string { + const start = content.indexOf(startMarker); + if (start === -1) return content; + const end = content.indexOf(endMarker, start); + if (end === -1) return content; + return content.slice(0, start) + content.slice(end + endMarker.length); +} + +/** + * full-backend's two yes/no follow-ups (Redis cache, realtime) are single + * conditional blocks each — small enough to apply as text edits directly + * on the copied template instead of a whole alternate file set. + */ +function applyFullBackendToggles(targetDir: string, answers: ProfileAnswers) { + const needsRedis = answers.enableRedisCache || answers.enableRealtime; + + if (!answers.enableRedisCache) { + const bootstrapPath = join(targetDir, "src/bootstrap.ts"); + let content = readFileSync(bootstrapPath, "utf-8"); + content = content.replace( + `import { + configureCache, + initializeDatabase, + RedisCacheStore, +} from "@donilite/wrap";`, + `import { initializeDatabase } from "@donilite/wrap";`, + ); + content = stripBlock( + content, + "// Cache backend: Redis when REDIS_URL is set, in-memory otherwise.", + "}\n", + ); + writeFileSync(bootstrapPath, content.trimEnd() + "\n"); + } + + if (!answers.enableRealtime) { + const indexPath = join(targetDir, "src/index.ts"); + let content = readFileSync(indexPath, "utf-8"); + + // Order matters: collapse the listen() call to its plain form FIRST, + // while `realtime.websocket` is still in scope for the literal match — + // removing the `const realtime = ...` declaration before this would + // leave nothing for this replace to match. + content = content.replace( + `const server = app.listen(appConfig.port, appConfig.host, { + websocket: realtime.websocket, +}); +realtime.attach(server); +realtime.bindEntityEvents(); +`, + `const server = app.listen(appConfig.port, appConfig.host);`, + ); + + content = content.replace( + `import { createRealtime } from "@donilite/wrap/realtime";\n`, + "", + ); + + content = content.replace( + ` +// Realtime: native Bun WebSocket topics + optional Redis relay for +// multi-instance fan-out. Entity writes are auto-published on +// \`entity:\` channels. Uses the \`.raw\` escape hatch — realtime needs +// the underlying Hono instance and the raw Bun.serve server handle. +const realtime = createRealtime({ redisUrl: process.env.REDIS_URL }); +app.get("/realtime", realtime.upgrade); + +`, + "\n", + ); + + writeFileSync(indexPath, content); + } + + if (!needsRedis) { + const composePath = join(targetDir, "compose.yml"); + if (existsSync(composePath)) { + let content = readFileSync(composePath, "utf-8"); + content = content.replace( + /\n {2}\S+_redis:\n(?: {4}.*\n)+/, + "\n", + ); + writeFileSync(composePath, content); + } + + const envPath = join(targetDir, ".env.example"); + if (existsSync(envPath)) { + let content = readFileSync(envPath, "utf-8"); + content = content.replace( + /# Redis \(cache backend \+ realtime multi-instance relay\)\nREDIS_PORT="6379"\nREDIS_URL="redis:\/\/localhost:\$\{REDIS_PORT\}"\n\n/, + "", + ); + writeFileSync(envPath, content); + } + } +} + +/** + * Scaffold a project: copy the base template, apply the profile's + * overlay (a no-op for full-backend, which the base template already + * matches), apply full-backend's own toggles, then the common + * housekeeping every profile shares (package.json patching, .gitignore + * rename, .env seeding). + */ +export function scaffoldProject(config: Config, answers: ProfileAnswers, wrapVersion: string) { + copyTree(TEMPLATE_DIR, config.targetDir, config); + + if (answers.profile === "full-backend") { + applyFullBackendToggles(config.targetDir, answers); + } else { + applyProfileOverlay(answers.profile, config.targetDir, config); + } + + // Patch package.json: project name + real @donilite/wrap version (the + // template/profile files use a workspace link inside the monorepo). + const pkgPath = join(config.targetDir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + pkg.name = toSnakeCase(config.projectName); + if (pkg.dependencies?.["@donilite/wrap"]) { + pkg.dependencies["@donilite/wrap"] = wrapVersion; + } + writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); + + // Rename gitignore.template -> .gitignore (npm ignores .gitignore files + // inside a published package, so it's shipped renamed). + const gitignoreTemplate = join(config.targetDir, "gitignore.template"); + const gitignore = join(config.targetDir, ".gitignore"); + if (existsSync(gitignoreTemplate)) { + cpSync(gitignoreTemplate, gitignore); + rmSync(gitignoreTemplate); + } + + // Seed .env from .env.example. + const envExample = join(config.targetDir, ".env.example"); + const envFile = join(config.targetDir, ".env"); + if (existsSync(envExample) && !existsSync(envFile)) { + cpSync(envExample, envFile); + } +} diff --git a/packages/create-wrap/src/utils.ts b/packages/create-wrap/src/utils.ts new file mode 100644 index 0000000..3760433 --- /dev/null +++ b/packages/create-wrap/src/utils.ts @@ -0,0 +1,32 @@ +export function log( + message: string, + type: "info" | "success" | "error" | "warn" = "info", +) { + const colors = { + info: "\x1b[36m", + success: "\x1b[32m", + error: "\x1b[31m", + warn: "\x1b[33m", + }; + const reset = "\x1b[0m"; + const icons = { + info: "ℹ", + success: "✔", + error: "✖", + warn: "⚠", + }; + console.log(`${colors[type]}${icons[type]} ${message}${reset}`); +} + +export function toSnakeCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, "$1_$2") + .replace(/[\s-]+/g, "_") + .toLowerCase(); +} + +export function toPascalCase(str: string): string { + return str + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")) + .replace(/^(.)/, (c) => c.toUpperCase()); +} diff --git a/packages/create-wrap/template/tests/auth.combine.test.ts b/packages/create-wrap/template/tests/auth.combine.test.ts index 708cd5c..de7353b 100644 --- a/packages/create-wrap/template/tests/auth.combine.test.ts +++ b/packages/create-wrap/template/tests/auth.combine.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "bun:test"; import type { Context } from "hono"; -import { AuthController, JwtCookieAuthController, Wrap, type AuthIdentity } from "@donilite/wrap"; +import { + AuthController, + JwtCookieAuthController, + Wrap, + type AuthIdentity, + type OpenApiSecuritySchemes, +} from "@donilite/wrap"; import { UserRoles } from "@/helpers/access.helper"; const SECRET = "combine-test"; @@ -29,7 +35,7 @@ class LegacyHeaderAuthController extends AuthController { this.revokeCalls += 1; } - override openApiSecurityScheme(): Record { + override openApiSecurityScheme(): OpenApiSecuritySchemes { return { legacyToken: { type: "apiKey", in: "header", name: "X-Legacy-Token" } }; } } diff --git a/packages/create-wrap/template/tests/swagger.test.ts b/packages/create-wrap/template/tests/swagger.test.ts index 31da1b1..5789b46 100644 --- a/packages/create-wrap/template/tests/swagger.test.ts +++ b/packages/create-wrap/template/tests/swagger.test.ts @@ -99,6 +99,26 @@ class NestedParentController extends RouterController { // way resolveControllerPath() has anything recorded to walk. new Wrap().register(NestedParentController); +// Route-level tags: a route's own `tags` should REPLACE the controller's +// tags for that operation only, not merge with them. +@Controller({ basePath: '/tag-override', tags: ['Parent'] }) +class TagOverrideTestController extends RouterController { + constructor() { + super(webFactory.createApp()); + } + + @Get({ path: '/inherits' }) + async inheritsControllerTag(c: Context) { + return c.json({ ok: true }); + } + + @Get({ path: '/overrides', tags: ['Parent: Child'] }) + async overridesControllerTag(c: Context) { + return c.json({ ok: true }); + } +} +void TagOverrideTestController; + describe('SwaggerGenerator', () => { it('derives path parameters from :id routes instead of requiring route.params', () => { const generator = new SwaggerGenerator({ title: 'Test', version: '0.0.0' }); @@ -179,4 +199,38 @@ describe('SwaggerGenerator', () => { // produced this path instead — assert it's NOT what got generated. expect(spec.paths['/nested-child/{id}']).toBeUndefined(); }); + + it('merges config.tags descriptions into the generated top-level tags instead of discarding them', () => { + const generator = new SwaggerGenerator({ + title: 'Test', + version: '0.0.0', + tags: [{ name: 'Secure', description: 'Endpoints requiring authentication' }], + }); + const spec = generator.generateSpec(); + + const secureTag = spec.tags.find((t: { name: string }) => t.name === 'Secure'); + expect(secureTag).toEqual({ + name: 'Secure', + description: 'Endpoints requiring authentication', + }); + + // A controller-declared tag with no matching config entry still falls + // back to a bare { name }. + const nestedTag = spec.tags.find((t: { name: string }) => t.name === 'Nested'); + expect(nestedTag).toEqual({ name: 'Nested' }); + }); + + it("a route's own tags replace (not merge with) its controller's tags", () => { + const generator = new SwaggerGenerator({ title: 'Test', version: '0.0.0' }); + const spec = generator.generateSpec(); + + const inherited = spec.paths['/tag-override/inherits']?.get; + expect(inherited.tags).toEqual(['Parent']); + + const overridden = spec.paths['/tag-override/overrides']?.get; + expect(overridden.tags).toEqual(['Parent: Child']); + + // The overriding tag must still surface in the top-level tags list. + expect(spec.tags.some((t: { name: string }) => t.name === 'Parent: Child')).toBe(true); + }); }); diff --git a/packages/wrap/src/decorators/interfaces.ts b/packages/wrap/src/decorators/interfaces.ts index f323f42..22d6847 100644 --- a/packages/wrap/src/decorators/interfaces.ts +++ b/packages/wrap/src/decorators/interfaces.ts @@ -31,6 +31,15 @@ export interface RouteOptions { query?: Record; deprecated?: boolean; handler?: string; + /** + * OpenAPI tags for this specific route — when present, REPLACES (not + * merges with) the owning controller's `@Controller({ tags })` for this + * operation only. Lets a child controller's routes carry their own tag + * identity distinct from routes that should stay under the parent's tag. + * See the tag-nesting note in `swagger/index.ts`'s `generateSpec()` for + * why this is a flat-namespace lever, not real nesting. + */ + tags?: string[]; } export interface CacheOptions { diff --git a/packages/wrap/src/middleware/auth/auth.controller.ts b/packages/wrap/src/middleware/auth/auth.controller.ts index faa9df9..d5faa14 100644 --- a/packages/wrap/src/middleware/auth/auth.controller.ts +++ b/packages/wrap/src/middleware/auth/auth.controller.ts @@ -40,6 +40,35 @@ export function isAuthController(value: unknown): value is AuthController { type AuthEnv = { Variables: AppVariables }; type AuthContext = Context; +/** + * Strongly-typed OpenAPI 3.0.3 Security Scheme Object — the shapes an + * `AuthController.openApiSecurityScheme()` implementation is allowed to + * return, keyed by scheme name (matching `components.securitySchemes` in + * the generated spec). Modeled directly on the spec's four `type` variants + * (https://spec.openapis.org/oas/v3.0.3#security-scheme-object) instead of + * a loose `Record` bag, so implementers get + * autocomplete/type-checking on exactly what each scheme type requires. + */ +export type OpenApiSecurityScheme = + | { type: "http"; scheme: string; bearerFormat?: string; description?: string } + | { type: "apiKey"; in: "header" | "query" | "cookie"; name: string; description?: string } + | { type: "oauth2"; flows: OpenApiOAuthFlows; description?: string } + | { type: "openIdConnect"; openIdConnectUrl: string; description?: string }; + +export interface OpenApiOAuthFlow { + refreshUrl?: string; + scopes: Record; +} + +export interface OpenApiOAuthFlows { + implicit?: OpenApiOAuthFlow & { authorizationUrl: string }; + password?: OpenApiOAuthFlow & { tokenUrl: string }; + clientCredentials?: OpenApiOAuthFlow & { tokenUrl: string }; + authorizationCode?: OpenApiOAuthFlow & { authorizationUrl: string; tokenUrl: string }; +} + +export type OpenApiSecuritySchemes = Record; + /** * Paradigm-agnostic auth contract. Every request-authentication strategy * (JWT + cookie, DB-backed sessions, API keys, OAuth token introspection, @@ -112,14 +141,14 @@ export abstract class AuthController { /** * OpenAPI security scheme(s) this strategy contributes (Wrap.swagger() - * hook). Empty by default; concrete presets override. An instance method - * (not static) so a combined controller (`AuthController.combine(...)`) - * can report the merged schemes of whatever it wraps — a static method - * has no way to reach that per-instance data. + * hook). Mandatory — every paradigm has *some* way a client authenticates, + * and leaving this un-overridden silently produced an empty/misleading + * `securitySchemes` block in generated docs. An instance method (not + * static) so a combined controller (`AuthController.combine(...)`) can + * report the merged schemes of whatever it wraps — a static method has no + * way to reach that per-instance data. */ - openApiSecurityScheme(): Record { - return {}; - } + abstract openApiSecurityScheme(): OpenApiSecuritySchemes; /** * Combine several `AuthController`s into one fallback chain: each is @@ -166,7 +195,7 @@ class CombinedAuthController extends AuthController { } } - override openApiSecurityScheme(): Record { + override openApiSecurityScheme(): OpenApiSecuritySchemes { return Object.assign( {}, ...this.controllers.map((controller) => controller.openApiSecurityScheme()), diff --git a/packages/wrap/src/middleware/auth/auth.middleware.ts b/packages/wrap/src/middleware/auth/auth.middleware.ts index 41d32f1..b2917cb 100644 --- a/packages/wrap/src/middleware/auth/auth.middleware.ts +++ b/packages/wrap/src/middleware/auth/auth.middleware.ts @@ -1,7 +1,13 @@ import { JwtCookieAuthController } from "./jwt-cookie.controller"; import type { Auth, AuthOptions } from "./types"; -export { AuthController } from "./auth.controller"; +export { AuthController, isAuthController } from "./auth.controller"; +export type { + OpenApiSecurityScheme, + OpenApiSecuritySchemes, + OpenApiOAuthFlow, + OpenApiOAuthFlows, +} from "./auth.controller"; export { JwtCookieAuthController } from "./jwt-cookie.controller"; export type { Auth, AuthOptions } from "./types"; export { JWTSessionBase, JWTSession } from "./types"; diff --git a/packages/wrap/src/middleware/auth/jwt-cookie.controller.ts b/packages/wrap/src/middleware/auth/jwt-cookie.controller.ts index 47a22b8..326269f 100644 --- a/packages/wrap/src/middleware/auth/jwt-cookie.controller.ts +++ b/packages/wrap/src/middleware/auth/jwt-cookie.controller.ts @@ -5,7 +5,7 @@ import { sign, verify } from "hono/jwt"; import { logger } from "../../logger"; import { canAccess } from "../../decorators/access"; import type { AppRoles, AppVariables, AuthIdentity } from "../../registry"; -import { AuthController } from "./auth.controller"; +import { AuthController, type OpenApiSecuritySchemes } from "./auth.controller"; import type { AuthOptions } from "./types"; import { JWTSessionBase } from "./types"; @@ -115,7 +115,7 @@ export class JwtCookieAuthController extends AuthController { this.revoke(c); } - override openApiSecurityScheme(): Record { + override openApiSecurityScheme(): OpenApiSecuritySchemes { return { bearerAuth: { type: "http", diff --git a/packages/wrap/src/swagger/index.ts b/packages/wrap/src/swagger/index.ts index 63fa53b..82740a8 100644 --- a/packages/wrap/src/swagger/index.ts +++ b/packages/wrap/src/swagger/index.ts @@ -12,7 +12,11 @@ import { resolveControllerPath, } from "../decorators"; import { getAllDTOs } from "../decorators"; -import { WRAP_AUTH_MIDDLEWARE, type AuthController } from "../middleware/auth/auth.controller"; +import { + WRAP_AUTH_MIDDLEWARE, + type AuthController, + type OpenApiSecuritySchemes, +} from "../middleware/auth/auth.controller"; export interface SwaggerConfig { title: string; @@ -23,7 +27,7 @@ export interface SwaggerConfig { } /** Security schemes used when no `AuthController` is registered on the generator. */ -const DEFAULT_SECURITY_SCHEMES: Record = { +const DEFAULT_SECURITY_SCHEMES: OpenApiSecuritySchemes = { bearerAuth: { type: "http", scheme: "bearer", @@ -65,7 +69,7 @@ export class SwaggerGenerator { const paths: Record = {}; const tags = new Set(); - const securitySchemes = + const securitySchemes: OpenApiSecuritySchemes = this.authController?.openApiSecurityScheme() ?? DEFAULT_SECURITY_SCHEMES; // Iterate through all registered controllers @@ -99,6 +103,12 @@ export class SwaggerGenerator { paths[fullPath] = {}; } + // A route's own `tags` (when set) replace the controller's tags for + // this operation only — see the tag-nesting note below for why this + // exists and what it can/can't do. + const operationTags = route.tags && route.tags.length > 0 ? route.tags : controllerTags || []; + operationTags.forEach((tag) => tags.add(tag)); + // Build operation object const operation: any = { summary: @@ -106,7 +116,7 @@ export class SwaggerGenerator { route.description || `${method.toUpperCase()} ${fullPath}`, description: route.description, - tags: controllerTags || [], + tags: operationTags, }; // Add deprecated flag @@ -318,7 +328,30 @@ export class SwaggerGenerator { servers: this.config.servers || [ { url: "http://localhost:3000", description: "Development server" }, ], - tags: Array.from(tags).map((tag) => ({ name: tag })), + // Merge in `config.tags` (by name) so caller-supplied descriptions + // survive — a tag declared only on a controller/route (no matching + // config entry) falls back to a bare `{ name }`. + // + // On "nested" tags: OpenAPI/stock swagger-ui tags are a FLAT + // namespace — there is no parent/child relationship. An operation + // with `tags: ["parent", "enfant"]` legitimately appears under BOTH + // the "parent" AND "enfant" sections in swagger-ui; it does not + // render as "parent > enfant". ReDoc has a vendor extension, + // `x-tagGroups`, that *visually* nests tags in its sidebar — but + // that's ReDoc-only (confirmed against Redocly's own vendor-extension + // docs), not part of the OpenAPI spec, and not rendered by + // `@hono/swagger-ui` (stock swagger-ui-dist). So true hierarchical + // grouping isn't achievable here without swapping the UI renderer. + // The practical workaround with the flat model: give each level its + // own tag name using a naming convention, e.g. `"Parent"` and + // `"Parent: Child"` — visually adjacent (alphabetical sort) and + // unambiguous, without claiming nesting the spec doesn't have. + // `RouteOptions.tags` (see below) is the lever for that: it lets a + // route opt out of its controller's tag and declare its own. + tags: Array.from(tags).map((tag) => { + const configured = this.config.tags?.find((t) => t.name === tag); + return configured ?? { name: tag }; + }), paths, components: { schemas: this.schemas,