From 887604fa5ae655a67c5fd5d0f63afd6ea2550e5e Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 16:50:40 +0000 Subject: [PATCH 1/7] feat!: mandatory, strongly-typed AuthController security schemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openApiSecurityScheme() was a concrete method defaulting to {} and typed as Record — subclasses could silently ship no security scheme (producing a misleading/empty securitySchemes block in generated docs) and implementers got no autocomplete/type-checking on the shape OpenAPI actually expects. Make it abstract (every auth paradigm has *some* client-facing scheme to document) and replace the loose bag with OpenApiSecurityScheme/ OpenApiSecuritySchemes, modeled directly on the OpenAPI 3.0.3 Security Scheme Object's four `type` variants (http, apiKey, oauth2, openIdConnect). Breaking change, intentional per maintainer — this repo is pre-1.0 and actively developed. --- .../src/middleware/auth/auth.controller.ts | 45 +++++++++++++++---- .../src/middleware/auth/auth.middleware.ts | 8 +++- 2 files changed, 44 insertions(+), 9 deletions(-) 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"; From e50d12d94293324001ded0469d42a33504efdc47 Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 16:51:39 +0000 Subject: [PATCH 2/7] refactor: retype AuthController implementers to OpenApiSecuritySchemes Follow-up to the abstract openApiSecurityScheme() change: update every current implementer (JwtCookieAuthController, CombinedAuthController, swagger/index.ts's DEFAULT_SECURITY_SCHEMES + securitySchemes local, and the template's hand-rolled LegacyHeaderAuthController test double) to the new strongly-typed return shape instead of Record. --- .../create-wrap/template/tests/auth.combine.test.ts | 10 ++++++++-- .../wrap/src/middleware/auth/jwt-cookie.controller.ts | 4 ++-- packages/wrap/src/swagger/index.ts | 10 +++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) 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/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..bd23e7a 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 From 8776f4bd410e202cf0ae4ce4f6e5506f77cd1bd4 Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 17:04:49 +0000 Subject: [PATCH 3/7] fix: swagger drops config.tags descriptions, add per-route tag override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things bundled here since they're both about generateSpec()'s tag handling: 1. Bug fix: the top-level `tags` array was built as `Array.from(tags).map(tag => ({ name: tag }))`, which silently discarded `SwaggerConfig.tags` — callers passing real descriptions for their tags never saw them in the generated spec. Now merges by name, falling back to a bare `{ name }` for tags with no config entry. 2. New `RouteOptions.tags`: a route can now declare its own tags, replacing (not merging with) its controller's tags for that operation. The second change responds to a maintainer report of a parent/child controller tag setup collapsing into "one summary" instead of a nested grouping. Verified against Redocly's own vendor-extension docs: `x-tagGroups` (visual tag nesting) is ReDoc-only, not part of the OpenAPI spec, and not rendered by `@hono/swagger-ui` (stock swagger-ui-dist). OpenAPI tags are a flat namespace — an operation with multiple tags legitimately appears under every one of them, it does not nest. True hierarchical grouping isn't achievable without swapping the UI renderer. `RouteOptions.tags` is the closest spec-compliant lever: it lets a child controller's routes take on their own tag identity (e.g. "Parent: Child" by convention) instead of inheriting the parent's tag wholesale. Findings also written up as a comment at the tag-handling site in swagger/index.ts. --- .../template/tests/swagger.test.ts | 54 +++++++++++++++++++ packages/wrap/src/decorators/interfaces.ts | 9 ++++ packages/wrap/src/swagger/index.ts | 33 +++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) 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/swagger/index.ts b/packages/wrap/src/swagger/index.ts index bd23e7a..82740a8 100644 --- a/packages/wrap/src/swagger/index.ts +++ b/packages/wrap/src/swagger/index.ts @@ -103,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: @@ -110,7 +116,7 @@ export class SwaggerGenerator { route.description || `${method.toUpperCase()} ${fullPath}`, description: route.description, - tags: controllerTags || [], + tags: operationTags, }; // Add deprecated flag @@ -322,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, From a16c3f407d92d85c53015e9524a36ae0f3259254 Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 20:07:44 +0000 Subject: [PATCH 4/7] feat: interactive, profile-driven create-wrap CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI only ever asked for a project name and copied one fixed template — every project got the full stack (Postgres, Redis, realtime, auth) whether it needed it or not. Rework it into a small interactive wizard (src/prompts.ts: numbered-menu select/confirm/text over Bun's stdin, hand-rolled rather than adding @clack/prompts as a dependency — this is a one-shot bunx scaffolding tool, so a dependency-light prompt is a better trade than a nicer TUI for a wizard with five questions) that asks what kind of project this is (src/profiles.ts) and scaffolds accordingly (src/scaffold.ts). Every profile is generated as a diff against the existing full-backend template (copy template/, delete what a profile doesn't need per profiles//remove.txt, layer profiles//files/ on top) rather than duplicated template trees — see the profiles/ commit that follows this one, and packages/create-wrap/profiles/README.md, for the actual profile content and mechanics writeup. full-backend itself (still the default, first choice) keeps two new yes/no follow-ups — Redis cache, realtime websockets — applied as small text edits to the copied template rather than their own file set, since each is a single conditional block. --- packages/create-wrap/package.json | 3 +- packages/create-wrap/src/index.ts | 177 +++--------------- packages/create-wrap/src/profiles.ts | 88 +++++++++ packages/create-wrap/src/prompts.ts | 75 ++++++++ packages/create-wrap/src/scaffold.ts | 262 +++++++++++++++++++++++++++ packages/create-wrap/src/utils.ts | 32 ++++ 6 files changed, 480 insertions(+), 157 deletions(-) create mode 100644 packages/create-wrap/src/profiles.ts create mode 100644 packages/create-wrap/src/prompts.ts create mode 100644 packages/create-wrap/src/scaffold.ts create mode 100644 packages/create-wrap/src/utils.ts 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/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()); +} From 590a1129bd8c670cb45539486fbbe5f6bb974bf3 Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 20:09:01 +0000 Subject: [PATCH 5/7] feat: lightweight-api and api-aggregator CLI profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two DB-free profiles, both scaffolded via the diff mechanism the CLI commit set up: lightweight-api (auth-only, a "greeting" feature slice) and api-aggregator (services fronting an upstream API, an "aggregator" feature slice with fetch injected for testability). Neither pulls in Postgres/Redis config, drizzle-kit, or pglite — see the "growing into a database" section of each profile's README for what to add back if a project outgrows this shape. Both example services follow the same @Service()/@ValidateDTO() convention an entity-backed BaseService uses (ServiceFactory singleton lookup, request-body validation replacing the method argument before the body runs) even though neither has a repository — both decorators are fully generic in @donilite/wrap already, confirmed with a throwaway script before writing this (WrapService + @Service() + @ValidateDTO(), zero framework changes needed). api-aggregator's tests stub `globalThis.fetch` at module-eval time in tests/swagger.test.ts, before that file's `new Wrap().register(...)` call — the earliest point anything constructs the AggregatorService singleton — so the whole suite never makes a real network call through ServiceFactory's process-wide cache, regardless of which test file happens to touch the aggregator routes first. Verified by scaffolding both into the monorepo workspace (temporarily, so hono/etc. hoist the same way packages/create-wrap/template's own verification does) and running install/typecheck/lint/test against the generated output, not just checking files got copied. --- .../api-aggregator/files/.env.example | 18 +++ .../profiles/api-aggregator/files/README.md | 54 +++++++ .../api-aggregator/files/package.json | 38 +++++ .../api-aggregator/files/src/bootstrap.ts | 14 ++ .../files/src/config/app.config.ts | 105 ++++++++++++ .../files/src/factory/web.factory.ts | 31 ++++ .../features/aggregator/DTO/aggregator.dto.ts | 27 ++++ .../aggregator/services/aggregator.service.ts | 57 +++++++ .../aggregator/web/aggregator.controller.ts | 69 ++++++++ .../files/src/index.controller.ts | 28 ++++ .../api-aggregator/files/src/index.ts | 40 +++++ .../files/tests/swagger.test.ts | 36 +++++ .../api-aggregator/files/tests/wrap.test.ts | 151 ++++++++++++++++++ .../profiles/api-aggregator/remove.txt | 17 ++ .../lightweight-api/files/.env.example | 15 ++ .../profiles/lightweight-api/files/README.md | 56 +++++++ .../lightweight-api/files/package.json | 38 +++++ .../lightweight-api/files/src/bootstrap.ts | 14 ++ .../files/src/config/app.config.ts | 96 +++++++++++ .../files/src/factory/web.factory.ts | 31 ++++ .../src/features/greeting/DTO/greeting.dto.ts | 21 +++ .../greeting/services/greeting.service.ts | 34 ++++ .../greeting/web/greeting.controller.ts | 75 +++++++++ .../files/src/index.controller.ts | 28 ++++ .../lightweight-api/files/src/index.ts | 40 +++++ .../files/tests/swagger.test.ts | 32 ++++ .../lightweight-api/files/tests/wrap.test.ts | 122 ++++++++++++++ .../profiles/lightweight-api/remove.txt | 17 ++ 28 files changed, 1304 insertions(+) create mode 100644 packages/create-wrap/profiles/api-aggregator/files/.env.example create mode 100644 packages/create-wrap/profiles/api-aggregator/files/README.md create mode 100644 packages/create-wrap/profiles/api-aggregator/files/package.json create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/bootstrap.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/config/app.config.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/factory/web.factory.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/DTO/aggregator.dto.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/services/aggregator.service.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/features/aggregator/web/aggregator.controller.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/index.controller.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/src/index.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/tests/swagger.test.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/files/tests/wrap.test.ts create mode 100644 packages/create-wrap/profiles/api-aggregator/remove.txt create mode 100644 packages/create-wrap/profiles/lightweight-api/files/.env.example create mode 100644 packages/create-wrap/profiles/lightweight-api/files/README.md create mode 100644 packages/create-wrap/profiles/lightweight-api/files/package.json create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/bootstrap.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/config/app.config.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/factory/web.factory.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/DTO/greeting.dto.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/services/greeting.service.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/features/greeting/web/greeting.controller.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/index.controller.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/src/index.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/tests/swagger.test.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/files/tests/wrap.test.ts create mode 100644 packages/create-wrap/profiles/lightweight-api/remove.txt 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/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 From f608686374d0ac1f196b7ff77ba6a5809c4b25fe Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 20:10:47 +0000 Subject: [PATCH 6/7] feat: fullstack-ssr CLI profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend framework stays TanStack Router/React-agnostic on purpose (that's a frontend-ecosystem choice, not something @donilite/wrap should mandate) — this profile is scaffolding only: it wires TanStack Router's route tree (src/ssr/routes.tsx) to a server-side render pass (src/ssr/render.tsx, react-dom/server's renderToString + a per-request router built with createMemoryHistory) mounted as an ordinary Hono catch-all route in src/index.ts. No new Wrap/RouterController primitive was needed — `.get()` already covers it, confirming the mission brief's expectation that this profile is mostly about scaffolding the right example code. What ships is a genuine, tested server round trip (tests/ssr.test.ts hits both routes and asserts on the rendered HTML, no framework changes required). What does NOT ship, flagged in render.tsx's header comment and the profile README rather than silently left out: client-side hydration — no Vite/esbuild bundle, no hydrateRoot(). Pages are server-rendered HTML only. Wiring a client bundle is real, separate work (pick a bundler, add a client entry point, serve built assets) intentionally left as a follow-up rather than guessed at unsupervised. JSON API routes moved under /api (see index.controller.ts) so they don't collide with SSR page paths at "/". --- .../profiles/fullstack-ssr/files/.env.example | 15 +++ .../profiles/fullstack-ssr/files/README.md | 57 +++++++++++ .../profiles/fullstack-ssr/files/package.json | 43 +++++++++ .../fullstack-ssr/files/src/bootstrap.ts | 14 +++ .../files/src/config/app.config.ts | 96 +++++++++++++++++++ .../files/src/factory/web.factory.ts | 31 ++++++ .../files/src/index.controller.ts | 26 +++++ .../profiles/fullstack-ssr/files/src/index.ts | 52 ++++++++++ .../fullstack-ssr/files/src/ssr/render.tsx | 31 ++++++ .../fullstack-ssr/files/src/ssr/router.ts | 16 ++++ .../fullstack-ssr/files/src/ssr/routes.tsx | 53 ++++++++++ .../fullstack-ssr/files/tests/ssr.test.ts | 33 +++++++ .../fullstack-ssr/files/tests/swagger.test.ts | 18 ++++ .../fullstack-ssr/files/tests/wrap.test.ts | 91 ++++++++++++++++++ .../fullstack-ssr/files/tsconfig.json | 34 +++++++ .../profiles/fullstack-ssr/remove.txt | 17 ++++ 16 files changed, 627 insertions(+) create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/.env.example create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/README.md create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/package.json create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/bootstrap.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/config/app.config.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/factory/web.factory.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/index.controller.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/index.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/render.tsx create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/router.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/src/ssr/routes.tsx create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/tests/ssr.test.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/tests/swagger.test.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/tests/wrap.test.ts create mode 100644 packages/create-wrap/profiles/fullstack-ssr/files/tsconfig.json create mode 100644 packages/create-wrap/profiles/fullstack-ssr/remove.txt 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 From 8fb98772b0facb8c1c0c027adfbc62d731773e9c Mon Sep 17 00:00:00 2001 From: DoniLite Date: Wed, 8 Jul 2026 20:12:40 +0000 Subject: [PATCH 7/7] feat: gateway CLI profile, document the profile-diff mechanics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxy/gateway profile: an HTTP reverse-proxy example using Hono's built-in proxy() helper (hono/proxy — the standard approach, checked it exists in the installed hono version rather than assuming), and a best-effort WebSocket proxy helper (src/gateway/ws-proxy.ts) relaying frames both directions over a native WebSocket client connection to the upstream, using the same hono/bun WebSocket primitive @donilite/wrap/realtime is built on. WS proxying is explicitly scoped as a starting point, not claimed as production-grade — src/gateway/ws-proxy.ts's header comment lists exactly what it does and doesn't handle (no backpressure propagation, no upstream reconnection, no built-in auth on the upgrade), and the profile README repeats the same warning rather than burying it. Also adds profiles/README.md, the mechanics writeup promised by comments in src/profiles.ts and src/scaffold.ts added earlier in this branch: how the copy/remove/overlay diff works, how to add a new profile, and a table of what each current profile drops/adds relative to the full-backend base — including the finding that drizzle-orm/drizzle-zod/pg stay as dependencies in every profile (even DB-free ones) because @donilite/wrap's own barrel imports them unconditionally at the module level (entity.ts, events.ts, dto.ts, database.ts), independent of whether a DB connection is ever established. Decoupling that is flagged as a separate follow-up, not attempted here. --- packages/create-wrap/profiles/README.md | 74 +++++++++++ .../profiles/gateway/files/.env.example | 18 +++ .../profiles/gateway/files/README.md | 50 ++++++++ .../profiles/gateway/files/package.json | 38 ++++++ .../profiles/gateway/files/src/bootstrap.ts | 14 ++ .../gateway/files/src/config/app.config.ts | 108 ++++++++++++++++ .../gateway/files/src/factory/web.factory.ts | 31 +++++ .../features/proxy/web/proxy.controller.ts | 41 ++++++ .../gateway/files/src/gateway/ws-proxy.ts | 120 ++++++++++++++++++ .../gateway/files/src/index.controller.ts | 32 +++++ .../profiles/gateway/files/src/index.ts | 52 ++++++++ .../gateway/files/tests/swagger.test.ts | 19 +++ .../profiles/gateway/files/tests/wrap.test.ts | 103 +++++++++++++++ .../create-wrap/profiles/gateway/remove.txt | 17 +++ 14 files changed, 717 insertions(+) create mode 100644 packages/create-wrap/profiles/README.md create mode 100644 packages/create-wrap/profiles/gateway/files/.env.example create mode 100644 packages/create-wrap/profiles/gateway/files/README.md create mode 100644 packages/create-wrap/profiles/gateway/files/package.json create mode 100644 packages/create-wrap/profiles/gateway/files/src/bootstrap.ts create mode 100644 packages/create-wrap/profiles/gateway/files/src/config/app.config.ts create mode 100644 packages/create-wrap/profiles/gateway/files/src/factory/web.factory.ts create mode 100644 packages/create-wrap/profiles/gateway/files/src/features/proxy/web/proxy.controller.ts create mode 100644 packages/create-wrap/profiles/gateway/files/src/gateway/ws-proxy.ts create mode 100644 packages/create-wrap/profiles/gateway/files/src/index.controller.ts create mode 100644 packages/create-wrap/profiles/gateway/files/src/index.ts create mode 100644 packages/create-wrap/profiles/gateway/files/tests/swagger.test.ts create mode 100644 packages/create-wrap/profiles/gateway/files/tests/wrap.test.ts create mode 100644 packages/create-wrap/profiles/gateway/remove.txt 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/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