From 5dc912405296b2dcc974983261b3d9b6db9458e4 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sat, 1 Aug 2026 12:03:43 -0400 Subject: [PATCH] feat: fullstack honors --server (Hono | Fastify | Express) (closes #29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fullstack preset always scaffolded Hono, ignoring --server / serviceFramework — so a documented override looked broken from the CLI, MCP, and web configurator. Now apps/server is generated per framework, with each serving /api/health (the shared Health shape) and, in production, the built web app from apps/web/dist: - Hono: @hono/node-server serveStatic (default; byte-identical to before) - Express: express.static + supertest tests; app typed as Express to satisfy the workspace's declaration emit (TS2742) - Fastify: @fastify/static (added to the version catalog) + inject tests Exposed across every surface, which was the other half of the bug: - serviceFramework's `when` and the CLI wizard now trigger for the fullstack layout, not only a `service` target, so the picker appears - the web command builder emits `--server` for fullstack, and also `--monorepo-layout` (a pre-existing gap — the fullstack command was unreproducible since 2.9) - preset/option help + the README reference say Hono/Fastify/Express Verified: Hono default byte-identical across all 48 monorepo/fullstack configs; express and fastify fullstack each install, build, typecheck, and pass their tests (real integration run); the web command round-trips through the CLI to a fullstack Express project. Added both to the integration matrix and the version-catalog conformance matrix. Closes #29 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/integration.yml | 2 + README.md | 2 + docs/app.js | 3 +- docs/packkit-core.js | 206 ++++++++++++++++++++++------- src/cli/wizard.js | 2 +- src/core/monorepo.js | 208 +++++++++++++++++++++++------- src/core/options.js | 6 +- src/core/presets.js | 2 +- src/core/versions.js | 1 + test/versions.test.js | 3 + 10 files changed, 334 insertions(+), 101 deletions(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 6751f95..b048255 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -38,6 +38,8 @@ jobs: - node-service - monorepo - fullstack + - fullstack --server express + - fullstack --server fastify - oss # Embedded API — generate through createProject/writeGeneratedProject, # add a deploy file, and confirm the same install/build/test/lint pass. diff --git a/README.md b/README.md index 7720660..bc2cfab 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ Every flag, its values (**default** in bold), and what it's for. Prefer the inte | `--server` | **hono** · fastify · express | For the service target: Hono (fast, web-standard, tiny — default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem). | | `--target` | **library** · cli · service · app | What you are building — mix and match: a library (importable package), a CLI (ships a bin), an HTTP service, or an app (Vite SPA). | | `--monorepo` | on / off (default: **off**) | Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when ≥2 packages share code. | +| `--monorepo-layout` | **libraries** · fullstack | What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono by default; --server for Fastify/Express) + packages/shared, wired together, with the server serving the web build in production. | | `--framework` | **none** · react · vue · svelte | UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package). | | `--pm` | **npm** · pnpm · yarn · bun | Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun. | | `--node` | 22 · **24** · 26 | Minimum Node line to support. Choices track Node’s own release schedule (Active LTS is the default); this sets engines + .nvmrc. | @@ -184,6 +185,7 @@ Named bundles of the options above — `npx packkit -y`. | `svelte-app` | `sapp` | Svelte SPA — Vite dev server, build, Testing Library. | | `node-service` | `svc`, `service` | Node HTTP service (Hono) — tsx dev, tsup build, Dockerfile. | | `monorepo` | — | pnpm + Turborepo workspace — two example packages, Changesets, CI. | +| `fullstack` | `fs`, `app` | Full-stack monorepo — React+Vite web, Hono/Fastify/Express API (--server), shared package; server serves the web build in production. | | `oss` | — | Full open-source library — coverage, CodeQL, Codecov, Renovate, Changesets. | | `minimal` | — | Bare TS library — tsup only, no tests/lint/CI. | | `full` | — | Everything on — library + CLI, all workflows and extras. | diff --git a/docs/app.js b/docs/app.js index fb3a689..f081f24 100644 --- a/docs/app.js +++ b/docs/app.js @@ -120,7 +120,7 @@ function commandFor(cfg) { // Metadata (previously missing from the command). flag('description', 'description'); flag('author', 'author'); flag('keywords', 'keywords'); flag('repo', 'repo'); flag('language', 'language'); flag('framework', 'framework'); flag('moduleFormat', 'module'); flag('bundler', 'bundler'); - if (cfg.target.includes('service')) flag('serviceFramework', 'server'); + if (cfg.target.includes('service') || cfg.monorepoLayout === 'fullstack') flag('serviceFramework', 'server'); flag('test', 'test'); flag('lint', 'lint'); flag('gitHooks', 'hooks'); flag('release', 'release'); flag('deps', 'deps'); flag('license', 'license'); flag('packageManager', 'pm'); flag('nodeVersion', 'node'); if (diff('target')) cfg.target.forEach((t) => parts.push(`--target ${t}`)); @@ -138,6 +138,7 @@ function commandFor(cfg) { if (cfg.publishable && cfg.sourcemaps === false) parts.push('--no-sourcemaps'); if (cfg.coverage === false && (cfg.test === 'vitest' || cfg.test === 'jest')) parts.push('--no-coverage'); if (cfg.monorepo) parts.push('--monorepo'); + if (cfg.monorepo && cfg.monorepoLayout && cfg.monorepoLayout !== 'libraries') parts.push(`--monorepo-layout ${cfg.monorepoLayout}`); for (const b of ['community', 'agents', 'vscode', 'editorconfig']) { if (cfg[b] === false && d[b] === true) parts.push(`--no-${b}`); } diff --git a/docs/packkit-core.js b/docs/packkit-core.js index 06435f1..a699403 100644 --- a/docs/packkit-core.js +++ b/docs/packkit-core.js @@ -47,7 +47,9 @@ var OPTIONS = { type: "select", label: "Service framework (HTTP service)", default: "hono", - when: (cfg) => cfg.target?.includes("service"), + // Applies to the service target, and to the fullstack monorepo (whose + // apps/server honors it too). + when: (cfg) => cfg.target?.includes("service") || cfg.monorepoLayout === "fullstack", choices: [ { value: "hono", label: "Hono (fast, web-standard)" }, { value: "fastify", label: "Fastify" }, @@ -262,7 +264,7 @@ var OPTION_HELP = { moduleFormat: "How the package is consumed. ESM-only (default) is the modern, leanest choice \u2014 Node 20.19+/22.12+ can `require()` ESM. Pick dual only if you must support older CJS-only consumers; cjs-only is rarely needed.", target: "What you are building \u2014 mix and match: a library (importable package), a CLI (ships a bin), an HTTP service, or an app (Vite SPA).", serviceFramework: "For the service target: Hono (fast, web-standard, tiny \u2014 default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).", - monorepoLayout: 'What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono) + packages/shared, wired together, with the server serving the web build in production.', + monorepoLayout: 'What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono by default; --server for Fastify/Express) + packages/shared, wired together, with the server serving the web build in production.', monorepo: "Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when \u22652 packages share code.", framework: "UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).", packageManager: "Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.", @@ -673,6 +675,7 @@ var V = { hono: "^4.5.0", "@hono/node-server": "^2.0.0", fastify: "^5.0.0", + "@fastify/static": "^8.0.0", express: "^5.0.0", "@types/express": "^5.0.0", zod: "^4.0.0" @@ -2660,6 +2663,8 @@ function buildFullstack(cfg) { `import { describeHealth } from './index.js';`, `expect(describeHealth({ ok: true, service: 'api', uptime: 12 })).toBe('api is up (12s)')` ); + const fw = cfg.serviceFramework || "hono"; + const server = fullstackServer(cfg, fw, shared); files["apps/server/package.json"] = toJson({ name: `@${scope}/server`, version: "0.0.0", @@ -2673,52 +2678,13 @@ function buildFullstack(cfg) { typecheck: "tsc --noEmit", lint: "eslint ." }, - dependencies: { hono: V.hono, "@hono/node-server": V["@hono/node-server"], [shared]: wsProto }, - devDependencies: { tsx: V.tsx, tsup: V.tsup } + dependencies: { ...server.deps, [shared]: wsProto }, + devDependencies: { tsx: V.tsx, tsup: V.tsup, ...server.devDeps } }); files["apps/server/tsconfig.json"] = toJson({ extends: "../../tsconfig.base.json", include: ["src"] }); - files["apps/server/src/app.ts"] = [ - `import { Hono } from 'hono';`, - `import { serveStatic } from '@hono/node-server/serve-static';`, - `import type { Health } from '${shared}';`, - ``, - `export const app = new Hono();`, - ``, - `app.get('/api/health', (c) => {`, - ` const body: Health = { ok: true, service: '${cfg.name}', uptime: process.uptime() };`, - ` return c.json(body);`, - `});`, - ``, - `// In production the API also serves the built web app, so one process and`, - `// one port covers the whole thing. In dev, Vite serves the app and proxies`, - `// /api here instead (see apps/web/vite.config.ts).`, - `if (process.env.NODE_ENV === 'production') {`, - ` app.use('/*', serveStatic({ root: '../web/dist' }));`, - `}`, - `` - ].join("\n"); - files["apps/server/src/index.ts"] = [ - `import { serve } from '@hono/node-server';`, - `import { app } from './app.js';`, - ``, - `const port = Number(process.env.PORT ?? 3000);`, - `serve({ fetch: app.fetch, port });`, - `console.log(\`Listening on http://localhost:\${port}\`);`, - `` - ].join("\n"); - files["apps/server/src/app.test.ts"] = [ - `import { describe, it, expect } from 'vitest';`, - `import { app } from './app.js';`, - ``, - `describe('api', () => {`, - ` it('reports health', async () => {`, - ` const res = await app.request('/api/health');`, - ` expect(res.status).toBe(200);`, - ` expect(await res.json()).toMatchObject({ ok: true });`, - ` });`, - `});`, - `` - ].join("\n"); + files["apps/server/src/app.ts"] = server.appTs; + files["apps/server/src/index.ts"] = server.indexTs; + files["apps/server/src/app.test.ts"] = server.appTestTs; files["apps/web/package.json"] = toJson({ name: `@${scope}/web`, version: "0.0.0", @@ -2834,14 +2800,156 @@ function buildFullstack(cfg) { summary: { name: cfg.name, fileCount: Object.keys(files).length, - stack: ["monorepo", "full-stack", `${pm}+turbo`, "React+Vite", "Hono", "TypeScript", "vitest"], + stack: ["monorepo", "full-stack", `${pm}+turbo`, "React+Vite", { hono: "Hono", fastify: "Fastify", express: "Express" }[cfg.serviceFramework || "hono"], "TypeScript", "vitest"], workflows: ["ci"] } }; } +function fullstackServer(cfg, fw, shared) { + const healthBody = `{ ok: true, service: '${cfg.name}', uptime: process.uptime() }`; + if (fw === "fastify") { + return { + deps: { fastify: V.fastify, "@fastify/static": V["@fastify/static"] }, + devDeps: {}, + appTs: [ + `import Fastify from 'fastify';`, + `import fastifyStatic from '@fastify/static';`, + `import { resolve } from 'node:path';`, + `import type { Health } from '${shared}';`, + ``, + `export const app = Fastify();`, + ``, + `app.get('/api/health', async (): Promise => (${healthBody}));`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + ` app.register(fastifyStatic, { root: resolve('../web/dist') });`, + `}`, + `` + ].join("\n"), + indexTs: [ + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `app.listen({ port, host: '0.0.0.0' }).then((url) => console.log(\`Listening on \${url}\`));`, + `` + ].join("\n"), + appTestTs: [ + `import { describe, it, expect } from 'vitest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + ` it('reports health', async () => {`, + ` const res = await app.inject({ method: 'GET', url: '/api/health' });`, + ` expect(res.statusCode).toBe(200);`, + ` expect(res.json()).toMatchObject({ ok: true });`, + ` });`, + `});`, + `` + ].join("\n") + }; + } + if (fw === "express") { + return { + deps: { express: V.express }, + devDeps: { "@types/express": V["@types/express"], supertest: V.supertest, "@types/supertest": V["@types/supertest"] }, + appTs: [ + // Annotate the app type: the workspace tsconfig emits declarations, and + // express()'s inferred type references a transitive package (TS2742). + `import express, { type Express } from 'express';`, + `import type { Health } from '${shared}';`, + ``, + `export const app: Express = express();`, + ``, + `app.get('/api/health', (_req, res) => {`, + ` const body: Health = ${healthBody};`, + ` res.json(body);`, + `});`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + ` app.use(express.static('../web/dist'));`, + `}`, + `` + ].join("\n"), + indexTs: [ + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `app.listen(port, () => console.log(\`Listening on http://localhost:\${port}\`));`, + `` + ].join("\n"), + appTestTs: [ + `import { describe, it, expect } from 'vitest';`, + `import request from 'supertest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + ` it('reports health', async () => {`, + ` const res = await request(app).get('/api/health');`, + ` expect(res.status).toBe(200);`, + ` expect(res.body).toMatchObject({ ok: true });`, + ` });`, + `});`, + `` + ].join("\n") + }; + } + return { + deps: { hono: V.hono, "@hono/node-server": V["@hono/node-server"] }, + devDeps: {}, + appTs: [ + `import { Hono } from 'hono';`, + `import { serveStatic } from '@hono/node-server/serve-static';`, + `import type { Health } from '${shared}';`, + ``, + `export const app = new Hono();`, + ``, + `app.get('/api/health', (c) => {`, + ` const body: Health = ${healthBody};`, + ` return c.json(body);`, + `});`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + ` app.use('/*', serveStatic({ root: '../web/dist' }));`, + `}`, + `` + ].join("\n"), + indexTs: [ + `import { serve } from '@hono/node-server';`, + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `serve({ fetch: app.fetch, port });`, + `console.log(\`Listening on http://localhost:\${port}\`);`, + `` + ].join("\n"), + appTestTs: [ + `import { describe, it, expect } from 'vitest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + ` it('reports health', async () => {`, + ` const res = await app.request('/api/health');`, + ` expect(res.status).toBe(200);`, + ` expect(await res.json()).toMatchObject({ ok: true });`, + ` });`, + `});`, + `` + ].join("\n") + }; +} function fullstackReadme(cfg, pm, shared) { const install = pm === "npm" ? "npm install" : `${pm} install`; const run2 = (s) => pm === "npm" ? `npm run ${s}` : `${pm} ${s}`; + const serverLabel = { hono: "Hono", fastify: "Fastify", express: "Express" }[cfg.serviceFramework || "hono"]; return [ `# ${cfg.name}`, "", @@ -2851,7 +2959,7 @@ function fullstackReadme(cfg, pm, shared) { "", "```", "apps/web React + Vite front end", - "apps/server Hono API (also serves the web build in production)", + `apps/server ${serverLabel} API (also serves the web build in production)`, "packages/shared types and helpers both sides import", "```", "", @@ -3124,7 +3232,7 @@ var PRESET_INFO = { "svelte-app": "Svelte SPA \u2014 Vite dev server, build, Testing Library.", "node-service": "Node HTTP service (Hono) \u2014 tsx dev, tsup build, Dockerfile.", monorepo: "pnpm + Turborepo workspace \u2014 two example packages, Changesets, CI.", - fullstack: "Full-stack monorepo \u2014 React+Vite web, Hono API, shared package; server serves the web build in production.", + fullstack: "Full-stack monorepo \u2014 React+Vite web, Hono/Fastify/Express API (--server), shared package; server serves the web build in production.", oss: "Full open-source library \u2014 coverage, CodeQL, Codecov, Renovate, Changesets.", minimal: "Bare TS library \u2014 tsup only, no tests/lint/CI.", full: "Everything on \u2014 library + CLI, all workflows and extras." diff --git a/src/cli/wizard.js b/src/cli/wizard.js index 7c0bca9..253c5f2 100644 --- a/src/cli/wizard.js +++ b/src/cli/wizard.js @@ -52,7 +52,7 @@ export async function runWizard(seed = {}) { } cfg.packageManager = bail(await p.select({ message: 'Package manager', options: asOptions('packageManager'), initialValue: OPTIONS.packageManager.default })); cfg.bundler = bail(await p.select({ message: 'Build / bundler', options: asOptions('bundler'), initialValue: OPTIONS.bundler.default })); - if (cfg.target.includes('service')) { + if (cfg.target.includes('service') || cfg.monorepoLayout === 'fullstack') { cfg.serviceFramework = bail(await p.select({ message: 'Service framework', options: asOptions('serviceFramework'), initialValue: OPTIONS.serviceFramework.default })); } cfg.test = bail(await p.select({ message: 'Test runner', options: asOptions('test'), initialValue: OPTIONS.test.default })); diff --git a/src/core/monorepo.js b/src/core/monorepo.js index c4f53f2..4513651 100644 --- a/src/core/monorepo.js +++ b/src/core/monorepo.js @@ -223,7 +223,9 @@ function buildFullstack(cfg) { `expect(describeHealth({ ok: true, service: 'api', uptime: 12 })).toBe('api is up (12s)')`, ); - // ---- apps/server ---- + // ---- apps/server ---- (honors cfg.serviceFramework: hono | fastify | express) + const fw = cfg.serviceFramework || 'hono'; + const server = fullstackServer(cfg, fw, shared); files['apps/server/package.json'] = toJson({ name: `@${scope}/server`, version: '0.0.0', @@ -237,52 +239,13 @@ function buildFullstack(cfg) { typecheck: 'tsc --noEmit', lint: 'eslint .', }, - dependencies: { hono: V.hono, '@hono/node-server': V['@hono/node-server'], [shared]: wsProto }, - devDependencies: { tsx: V.tsx, tsup: V.tsup }, + dependencies: { ...server.deps, [shared]: wsProto }, + devDependencies: { tsx: V.tsx, tsup: V.tsup, ...server.devDeps }, }); files['apps/server/tsconfig.json'] = toJson({ extends: '../../tsconfig.base.json', include: ['src'] }); - files['apps/server/src/app.ts'] = [ - `import { Hono } from 'hono';`, - `import { serveStatic } from '@hono/node-server/serve-static';`, - `import type { Health } from '${shared}';`, - ``, - `export const app = new Hono();`, - ``, - `app.get('/api/health', (c) => {`, - `\tconst body: Health = { ok: true, service: '${cfg.name}', uptime: process.uptime() };`, - `\treturn c.json(body);`, - `});`, - ``, - `// In production the API also serves the built web app, so one process and`, - `// one port covers the whole thing. In dev, Vite serves the app and proxies`, - `// /api here instead (see apps/web/vite.config.ts).`, - `if (process.env.NODE_ENV === 'production') {`, - `\tapp.use('/*', serveStatic({ root: '../web/dist' }));`, - `}`, - ``, - ].join('\n'); - files['apps/server/src/index.ts'] = [ - `import { serve } from '@hono/node-server';`, - `import { app } from './app.js';`, - ``, - `const port = Number(process.env.PORT ?? 3000);`, - `serve({ fetch: app.fetch, port });`, - `console.log(\`Listening on http://localhost:\${port}\`);`, - ``, - ].join('\n'); - files['apps/server/src/app.test.ts'] = [ - `import { describe, it, expect } from 'vitest';`, - `import { app } from './app.js';`, - ``, - `describe('api', () => {`, - `\tit('reports health', async () => {`, - `\t\tconst res = await app.request('/api/health');`, - `\t\texpect(res.status).toBe(200);`, - `\t\texpect(await res.json()).toMatchObject({ ok: true });`, - `\t});`, - `});`, - ``, - ].join('\n'); + files['apps/server/src/app.ts'] = server.appTs; + files['apps/server/src/index.ts'] = server.indexTs; + files['apps/server/src/app.test.ts'] = server.appTestTs; // ---- apps/web ---- files['apps/web/package.json'] = toJson({ @@ -401,15 +364,166 @@ function buildFullstack(cfg) { summary: { name: cfg.name, fileCount: Object.keys(files).length, - stack: ['monorepo', 'full-stack', `${pm}+turbo`, 'React+Vite', 'Hono', 'TypeScript', 'vitest'], + stack: ['monorepo', 'full-stack', `${pm}+turbo`, 'React+Vite', { hono: 'Hono', fastify: 'Fastify', express: 'Express' }[cfg.serviceFramework || 'hono'], 'TypeScript', 'vitest'], workflows: ['ci'], }, }; } +// The full-stack server, per framework. Each serves /api/health (returning the +// shared Health shape) and, in production, the built web app from apps/web/dist +// so one process on one port covers the whole thing. In dev, Vite serves the +// web app and proxies /api here (see apps/web/vite.config.ts). +function fullstackServer(cfg, fw, shared) { + const healthBody = `{ ok: true, service: '${cfg.name}', uptime: process.uptime() }`; + + if (fw === 'fastify') { + return { + deps: { fastify: V.fastify, '@fastify/static': V['@fastify/static'] }, + devDeps: {}, + appTs: [ + `import Fastify from 'fastify';`, + `import fastifyStatic from '@fastify/static';`, + `import { resolve } from 'node:path';`, + `import type { Health } from '${shared}';`, + ``, + `export const app = Fastify();`, + ``, + `app.get('/api/health', async (): Promise => (${healthBody}));`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + `\tapp.register(fastifyStatic, { root: resolve('../web/dist') });`, + `}`, + ``, + ].join('\n'), + indexTs: [ + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `app.listen({ port, host: '0.0.0.0' }).then((url) => console.log(\`Listening on \${url}\`));`, + ``, + ].join('\n'), + appTestTs: [ + `import { describe, it, expect } from 'vitest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + `\tit('reports health', async () => {`, + `\t\tconst res = await app.inject({ method: 'GET', url: '/api/health' });`, + `\t\texpect(res.statusCode).toBe(200);`, + `\t\texpect(res.json()).toMatchObject({ ok: true });`, + `\t});`, + `});`, + ``, + ].join('\n'), + }; + } + + if (fw === 'express') { + return { + deps: { express: V.express }, + devDeps: { '@types/express': V['@types/express'], supertest: V.supertest, '@types/supertest': V['@types/supertest'] }, + appTs: [ + // Annotate the app type: the workspace tsconfig emits declarations, and + // express()'s inferred type references a transitive package (TS2742). + `import express, { type Express } from 'express';`, + `import type { Health } from '${shared}';`, + ``, + `export const app: Express = express();`, + ``, + `app.get('/api/health', (_req, res) => {`, + `\tconst body: Health = ${healthBody};`, + `\tres.json(body);`, + `});`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + `\tapp.use(express.static('../web/dist'));`, + `}`, + ``, + ].join('\n'), + indexTs: [ + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `app.listen(port, () => console.log(\`Listening on http://localhost:\${port}\`));`, + ``, + ].join('\n'), + appTestTs: [ + `import { describe, it, expect } from 'vitest';`, + `import request from 'supertest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + `\tit('reports health', async () => {`, + `\t\tconst res = await request(app).get('/api/health');`, + `\t\texpect(res.status).toBe(200);`, + `\t\texpect(res.body).toMatchObject({ ok: true });`, + `\t});`, + `});`, + ``, + ].join('\n'), + }; + } + + // hono (default) + return { + deps: { hono: V.hono, '@hono/node-server': V['@hono/node-server'] }, + devDeps: {}, + appTs: [ + `import { Hono } from 'hono';`, + `import { serveStatic } from '@hono/node-server/serve-static';`, + `import type { Health } from '${shared}';`, + ``, + `export const app = new Hono();`, + ``, + `app.get('/api/health', (c) => {`, + `\tconst body: Health = ${healthBody};`, + `\treturn c.json(body);`, + `});`, + ``, + `// In production the API also serves the built web app, so one process and`, + `// one port covers the whole thing. In dev, Vite serves the app and proxies`, + `// /api here instead (see apps/web/vite.config.ts).`, + `if (process.env.NODE_ENV === 'production') {`, + `\tapp.use('/*', serveStatic({ root: '../web/dist' }));`, + `}`, + ``, + ].join('\n'), + indexTs: [ + `import { serve } from '@hono/node-server';`, + `import { app } from './app.js';`, + ``, + `const port = Number(process.env.PORT ?? 3000);`, + `serve({ fetch: app.fetch, port });`, + `console.log(\`Listening on http://localhost:\${port}\`);`, + ``, + ].join('\n'), + appTestTs: [ + `import { describe, it, expect } from 'vitest';`, + `import { app } from './app.js';`, + ``, + `describe('api', () => {`, + `\tit('reports health', async () => {`, + `\t\tconst res = await app.request('/api/health');`, + `\t\texpect(res.status).toBe(200);`, + `\t\texpect(await res.json()).toMatchObject({ ok: true });`, + `\t});`, + `});`, + ``, + ].join('\n'), + }; +} + function fullstackReadme(cfg, pm, shared) { const install = pm === 'npm' ? 'npm install' : `${pm} install`; const run = (s) => (pm === 'npm' ? `npm run ${s}` : `${pm} ${s}`); + const serverLabel = { hono: 'Hono', fastify: 'Fastify', express: 'Express' }[cfg.serviceFramework || 'hono']; return [ `# ${cfg.name}`, '', @@ -419,7 +533,7 @@ function fullstackReadme(cfg, pm, shared) { '', '```', 'apps/web React + Vite front end', - 'apps/server Hono API (also serves the web build in production)', + `apps/server ${serverLabel} API (also serves the web build in production)`, 'packages/shared types and helpers both sides import', '```', '', diff --git a/src/core/options.js b/src/core/options.js index 52a9319..98c4253 100644 --- a/src/core/options.js +++ b/src/core/options.js @@ -36,7 +36,9 @@ export const OPTIONS = { }, serviceFramework: { group: 'core', type: 'select', label: 'Service framework (HTTP service)', default: 'hono', - when: (cfg) => cfg.target?.includes('service'), + // Applies to the service target, and to the fullstack monorepo (whose + // apps/server honors it too). + when: (cfg) => cfg.target?.includes('service') || cfg.monorepoLayout === 'fullstack', choices: [ { value: 'hono', label: 'Hono (fast, web-standard)' }, { value: 'fastify', label: 'Fastify' }, @@ -218,7 +220,7 @@ export const OPTION_HELP = { moduleFormat: 'How the package is consumed. ESM-only (default) is the modern, leanest choice — Node 20.19+/22.12+ can `require()` ESM. Pick dual only if you must support older CJS-only consumers; cjs-only is rarely needed.', target: 'What you are building — mix and match: a library (importable package), a CLI (ships a bin), an HTTP service, or an app (Vite SPA).', serviceFramework: 'For the service target: Hono (fast, web-standard, tiny — default), Fastify (batteries-included, plugins, schema validation), or Express (ubiquitous, huge ecosystem).', - monorepoLayout: 'What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono) + packages/shared, wired together, with the server serving the web build in production.', + monorepoLayout: 'What the workspace contains. "libraries" gives linked packages you publish (Changesets). "fullstack" gives apps/web (React+Vite) + apps/server (Hono by default; --server for Fastify/Express) + packages/shared, wired together, with the server serving the web build in production.', monorepo: 'Generate a pnpm + Turborepo workspace with two linked example packages and Changesets. Only worth it when ≥2 packages share code.', framework: 'UI framework for component libraries and apps: React, Vue, or Svelte (or none for a plain package).', packageManager: 'Which package manager the scripts, lockfile, and CI target: npm, pnpm, yarn, or bun.', diff --git a/src/core/presets.js b/src/core/presets.js index 5a7eea8..4b9a10d 100644 --- a/src/core/presets.js +++ b/src/core/presets.js @@ -84,7 +84,7 @@ export const PRESET_INFO = { 'svelte-app': 'Svelte SPA — Vite dev server, build, Testing Library.', 'node-service': 'Node HTTP service (Hono) — tsx dev, tsup build, Dockerfile.', monorepo: 'pnpm + Turborepo workspace — two example packages, Changesets, CI.', - fullstack: 'Full-stack monorepo — React+Vite web, Hono API, shared package; server serves the web build in production.', + fullstack: 'Full-stack monorepo — React+Vite web, Hono/Fastify/Express API (--server), shared package; server serves the web build in production.', oss: 'Full open-source library — coverage, CodeQL, Codecov, Renovate, Changesets.', minimal: 'Bare TS library — tsup only, no tests/lint/CI.', full: 'Everything on — library + CLI, all workflows and extras.', diff --git a/src/core/versions.js b/src/core/versions.js index a0d7972..17de896 100644 --- a/src/core/versions.js +++ b/src/core/versions.js @@ -98,6 +98,7 @@ export const V = { hono: '^4.5.0', '@hono/node-server': '^2.0.0', fastify: '^5.0.0', + '@fastify/static': '^8.0.0', express: '^5.0.0', '@types/express': '^5.0.0', zod: '^4.0.0', diff --git a/test/versions.test.js b/test/versions.test.js index 52a5489..4a2e06b 100644 --- a/test/versions.test.js +++ b/test/versions.test.js @@ -26,6 +26,9 @@ const MATRIX = (() => { } for (const serviceFramework of servers) for (const language of ['ts', 'js']) { configs.push({ preset: 'node-service', overrides: { serviceFramework, language, env: true } }); + // fullstack honors serviceFramework too — exercises @fastify/static and the + // express supertest deps in the workspace server. + configs.push({ preset: 'fullstack', overrides: { serviceFramework } }); } return configs; })();