Feat/lightweight setup - #2
Conversation
openApiSecurityScheme() was a concrete method defaulting to {} and typed
as Record<string, unknown> — 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.
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<string, unknown>.
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.
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/<id>/remove.txt, layer profiles/<id>/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.
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.
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 "/".
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.
Reviewer's GuideRefactors the create-wrap CLI into a profile-driven scaffolder with reusable prompting/scaffolding utilities, introduces multiple non-DB project profiles, enhances Swagger tag handling, and tightens auth/OpenAPI typing and exports, plus adds targeted tests and docs for the new behaviors. File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The profile-specific scaffolding in
scaffold.ts(e.g.applyFullBackendToggles,applyProfileOverlay) relies on fairly brittle string/regex replacements and hard-coded markers; consider factoring these into more structured transformations or shared constants so template changes don’t silently break the toggles/overlays. - The new
wsProxyhelper assumes Bun’sWebSocketclient semantics (e.g.binaryType, close signatures) but doesn’t guard against unsupported environments or mismatched upstream implementations; it may be worth isolating those assumptions (and errors) behind a small adapter so future runtime changes don’t require touching the proxy logic directly.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The profile-specific scaffolding in `scaffold.ts` (e.g. `applyFullBackendToggles`, `applyProfileOverlay`) relies on fairly brittle string/regex replacements and hard-coded markers; consider factoring these into more structured transformations or shared constants so template changes don’t silently break the toggles/overlays.
- The new `wsProxy` helper assumes Bun’s `WebSocket` client semantics (e.g. `binaryType`, close signatures) but doesn’t guard against unsupported environments or mismatched upstream implementations; it may be worth isolating those assumptions (and errors) behind a small adapter so future runtime changes don’t require touching the proxy logic directly.
## Individual Comments
### Comment 1
<location path="packages/create-wrap/profiles/gateway/files/src/features/proxy/web/proxy.controller.ts" line_range="30-37" />
<code_context>
+ @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,
+ },
+ });
</code_context>
<issue_to_address>
**issue (bug_risk):** Avoid sending an `Authorization: undefined` header to the upstream when stripping auth.
Because `headers` is a plain object, setting `Authorization: undefined` is likely to result in an `Authorization: "undefined"` header being sent instead of omitting it. Please construct the headers so that `Authorization` is not present at all (e.g., build a `Headers` instance and `delete("Authorization")`, or avoid adding the property when spreading) to ensure the header is actually stripped.
</issue_to_address>
### Comment 2
<location path="packages/create-wrap/src/scaffold.ts" line_range="124-129" />
<code_context>
+}
+
+/** 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);
+}
+
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Profile toggles rely on brittle marker-based string slicing that may break with small template edits.
`stripBlock` and the hard-coded `replace` calls in `applyFullBackendToggles()` depend on exact comment text and brace/newline layout in `bootstrap.ts`, `index.ts`, `compose.yml`, and `.env.example`. Small formatting changes could stop the edits from working or remove the wrong block. Consider targeting more structured delimiters (e.g. `// BEGIN REDIS BLOCK` / `// END REDIS BLOCK`) or simple AST-based transforms, and at least make the end markers more specific than a bare `}` to reduce accidental removals.
Suggested implementation:
```typescript
/**
* Remove a contiguous block of lines between (and including) two markers, if both are found.
*
* Markers are intended to be structured delimiters (e.g. `// BEGIN REDIS BLOCK` / `// END REDIS BLOCK`)
* that appear on their own lines in the template. Avoid using generic markers such as a bare `}`.
*/
function stripBlock(content: string, startMarker: string, endMarker: string): string {
// Split into lines so we only ever remove whole lines between explicit delimiters.
const lines = content.split(/\r?\n/);
const startIndex = lines.findIndex((line) => line.includes(startMarker));
if (startIndex === -1) return content;
const endIndex = lines.findIndex(
(line, idx) => idx >= startIndex && line.includes(endMarker),
);
if (endIndex === -1) return content;
const keptLines = [
...lines.slice(0, startIndex),
...lines.slice(endIndex + 1),
];
return keptLines.join("\n");
}
const TEMPLATE_DIR = join(import.meta.dir, "..", "template");
```
To fully address the brittleness mentioned in your comment, you should also:
1. Add explicit BEGIN/END markers (e.g. `// BEGIN REDIS BLOCK` / `// END REDIS BLOCK`) in the relevant template files (`bootstrap.ts`, `index.ts`, `compose.yml`, `.env.example`) wrapping each profile-specific block.
2. Update `applyFullBackendToggles()` (and any other callers of `stripBlock`) to use those structured markers instead of hard-coded brace or newline-based substrings (especially replacing any use of a bare `}` as the end marker).
3. Ensure any future profile blocks follow the same marker convention so `stripBlock` remains robust against formatting changes within the block.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| 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, |
There was a problem hiding this comment.
issue (bug_risk): Avoid sending an Authorization: undefined header to the upstream when stripping auth.
Because headers is a plain object, setting Authorization: undefined is likely to result in an Authorization: "undefined" header being sent instead of omitting it. Please construct the headers so that Authorization is not present at all (e.g., build a Headers instance and delete("Authorization"), or avoid adding the property when spreading) to ensure the header is actually stripped.
| 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); |
There was a problem hiding this comment.
suggestion (bug_risk): Profile toggles rely on brittle marker-based string slicing that may break with small template edits.
stripBlock and the hard-coded replace calls in applyFullBackendToggles() depend on exact comment text and brace/newline layout in bootstrap.ts, index.ts, compose.yml, and .env.example. Small formatting changes could stop the edits from working or remove the wrong block. Consider targeting more structured delimiters (e.g. // BEGIN REDIS BLOCK / // END REDIS BLOCK) or simple AST-based transforms, and at least make the end markers more specific than a bare } to reduce accidental removals.
Suggested implementation:
/**
* Remove a contiguous block of lines between (and including) two markers, if both are found.
*
* Markers are intended to be structured delimiters (e.g. `// BEGIN REDIS BLOCK` / `// END REDIS BLOCK`)
* that appear on their own lines in the template. Avoid using generic markers such as a bare `}`.
*/
function stripBlock(content: string, startMarker: string, endMarker: string): string {
// Split into lines so we only ever remove whole lines between explicit delimiters.
const lines = content.split(/\r?\n/);
const startIndex = lines.findIndex((line) => line.includes(startMarker));
if (startIndex === -1) return content;
const endIndex = lines.findIndex(
(line, idx) => idx >= startIndex && line.includes(endMarker),
);
if (endIndex === -1) return content;
const keptLines = [
...lines.slice(0, startIndex),
...lines.slice(endIndex + 1),
];
return keptLines.join("\n");
}
const TEMPLATE_DIR = join(import.meta.dir, "..", "template");To fully address the brittleness mentioned in your comment, you should also:
- Add explicit BEGIN/END markers (e.g.
// BEGIN REDIS BLOCK/// END REDIS BLOCK) in the relevant template files (bootstrap.ts,index.ts,compose.yml,.env.example) wrapping each profile-specific block. - Update
applyFullBackendToggles()(and any other callers ofstripBlock) to use those structured markers instead of hard-coded brace or newline-based substrings (especially replacing any use of a bare}as the end marker). - Ensure any future profile blocks follow the same marker convention so
stripBlockremains robust against formatting changes within the block.
Summary by Sourcery
Introduce a profile-based scaffolding system for @donilite/create-wrap with multiple no-DB and SSR/gateway templates, centralised project scaffolding logic, and improved Swagger/auth typing and tagging behaviour.
New Features:
Enhancements:
Tests: