@pauldvlp/vp-react-ts-nestjs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/dist/index.js +188 -0
- package/package.json +46 -0
- package/template/README.md +122 -0
- package/template/_dockerignore +8 -0
- package/template/_gitignore +30 -0
- package/template/apps/api/Dockerfile +32 -0
- package/template/apps/api/_env.example +3 -0
- package/template/apps/api/_gitignore +8 -0
- package/template/apps/api/package.json +33 -0
- package/template/apps/api/src/app.module.ts +33 -0
- package/template/apps/api/src/common/zod-validation.pipe.ts +16 -0
- package/template/apps/api/src/config/config.module.ts +11 -0
- package/template/apps/api/src/config/env.ts +14 -0
- package/template/apps/api/src/health/health.controller.ts +9 -0
- package/template/apps/api/src/health/health.module.ts +8 -0
- package/template/apps/api/src/items/items.controller.ts +25 -0
- package/template/apps/api/src/items/items.module.ts +10 -0
- package/template/apps/api/src/items/items.service.spec.ts +26 -0
- package/template/apps/api/src/items/items.service.ts +24 -0
- package/template/apps/api/src/main.ts +30 -0
- package/template/apps/api/tsconfig.json +28 -0
- package/template/apps/api/vite.config.ts +35 -0
- package/template/apps/web/_gitignore +24 -0
- package/template/apps/web/index.html +13 -0
- package/template/apps/web/package.json +27 -0
- package/template/apps/web/public/favicon.svg +4 -0
- package/template/apps/web/src/App.tsx +48 -0
- package/template/apps/web/src/main.tsx +9 -0
- package/template/apps/web/tsconfig.app.json +31 -0
- package/template/apps/web/tsconfig.json +4 -0
- package/template/apps/web/tsconfig.node.json +23 -0
- package/template/apps/web/vite.config.ts +45 -0
- package/template/package.json +26 -0
- package/template/packages/contracts/package.json +24 -0
- package/template/packages/contracts/src/index.ts +18 -0
- package/template/packages/contracts/tsconfig.json +19 -0
- package/template/packages/contracts/vite.config.ts +20 -0
- package/template/pnpm-workspace.yaml +29 -0
- package/template/tsconfig.json +9 -0
- package/template/vite.config.ts +24 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pauldvlp
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/pauldvlp/vp-templates/main/assets/cover.webp" alt="@pauldvlp/vp-templates" width="100%" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# @pauldvlp/vp-react-ts-nestjs
|
|
6
|
+
|
|
7
|
+
A [Vite+](https://viteplus.dev) **monorepo generator** that scaffolds a full-stack workspace where the
|
|
8
|
+
front-end and the back-end share one toolchain:
|
|
9
|
+
|
|
10
|
+
- `apps/web` — a minimal React + Vite+ app that proxies `/api` to the server (no CORS)
|
|
11
|
+
- `apps/api` — a NestJS api conformed to Vite+ (no Webpack, no `nest` CLI)
|
|
12
|
+
- `packages/contracts` — shared **Zod** schemas + inferred types, the single source of truth for both ends
|
|
13
|
+
|
|
14
|
+
The api rides Vite+'s native **Oxc** transform, which emits `emitDecoratorMetadata` from `tsconfig.json`
|
|
15
|
+
— so Nest's dependency injection works with **no transform plugin**. Dev runs on `vite-node --watch`,
|
|
16
|
+
the production build is a Vite SSR bundle (`dist/main.js`), and a single `vp check` / `vp test` /
|
|
17
|
+
`vp run -r build` covers the whole workspace.
|
|
18
|
+
|
|
19
|
+
It's a [Bingo](https://create.bingo) template, so options can be passed on the `vp create` command line
|
|
20
|
+
(anything after `--`).
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
Published under the [`@pauldvlp/create`](../create) manifest:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Interactive (prompts for anything you don't pass)
|
|
28
|
+
vp create @pauldvlp:vp-react-ts-nestjs
|
|
29
|
+
|
|
30
|
+
# Non-interactive, fully specified
|
|
31
|
+
vp create @pauldvlp:vp-react-ts-nestjs -- \
|
|
32
|
+
--name my-app --scope @acme --apiPort 3000 --webPort 5173
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
> Only **string** options parse reliably as `vp create -- --flag value`. **Boolean** options
|
|
36
|
+
> (`--swagger`, `--serveWeb`, `--docker`, `--install`) are best left to the interactive prompt — Bingo's
|
|
37
|
+
> CLI does not accept `--no-x` / `--x=false` cleanly. Omit them and answer the prompt.
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
|
|
41
|
+
| Option | Type / values | Default | Notes |
|
|
42
|
+
| ------------ | ------------- | --------- | ---------------------------------------------------------------------------------------------- |
|
|
43
|
+
| `--name` | string | `my-app` | Root project / package name. |
|
|
44
|
+
| `--scope` | string | `@<name>` | npm scope for workspace packages → `@scope/web`, `@scope/api`, `@scope/contracts`. Defaults to the project name prefixed with `@` (e.g. `--name acme` → `@acme`); falls back to `@app`. |
|
|
45
|
+
| `--apiPort` | string | `3000` | Port the NestJS api listens on (substituted into the env default + the web proxy). |
|
|
46
|
+
| `--webPort` | string | `5173` | Port the web dev server listens on. |
|
|
47
|
+
| `--swagger` | boolean | `false` | Expose Swagger UI at `/docs` and the OpenAPI JSON at `/docs.json` (adds `@nestjs/swagger`). |
|
|
48
|
+
| `--serveWeb` | boolean | `false` | Have the api serve the built web app for a single deployable (adds `@nestjs/serve-static`). |
|
|
49
|
+
| `--docker` | boolean | `false` | Emit a multi-stage `apps/api/Dockerfile` (+ root `.dockerignore`). |
|
|
50
|
+
| `--install` | boolean | `true` | Run `pnpm install` after scaffolding. `false` = files only. |
|
|
51
|
+
|
|
52
|
+
## What it scaffolds
|
|
53
|
+
|
|
54
|
+
`produce()` reads the static monorepo skeleton under `template/`, rewriting the `@app` scope, project
|
|
55
|
+
name and ports, then conditionally wires the optional features via marker comments:
|
|
56
|
+
|
|
57
|
+
- **`--swagger`** swaps the `// __SWAGGER_*__` markers in `apps/api/src/main.ts` for a `DocumentBuilder` +
|
|
58
|
+
`SwaggerModule.setup('docs', …, { jsonDocumentUrl: 'docs.json' })`; otherwise the markers are stripped.
|
|
59
|
+
- **`--serveWeb`** swaps the `// __SERVEWEB_*__` markers in `apps/api/src/app.module.ts` for a
|
|
60
|
+
`ServeStaticModule.forRoot` pointing at `apps/web/dist` (excluding `/api/*`); otherwise stripped.
|
|
61
|
+
- **`--docker`** keeps `apps/api/Dockerfile` + the root `.dockerignore`; otherwise both are dropped.
|
|
62
|
+
|
|
63
|
+
The optional Nest deps (`@nestjs/swagger`, `@nestjs/serve-static`) are added to `apps/api/package.json`
|
|
64
|
+
only when their flag is on, and the README's `<!-- SWAGGER/SERVEWEB/DOCKER -->` doc blocks are kept or
|
|
65
|
+
dropped to match. With `--install`, `pnpm install` runs as a post-scaffold step.
|
|
66
|
+
|
|
67
|
+
## Develop the generator
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pnpm install
|
|
71
|
+
node bin/index.ts --help # list options
|
|
72
|
+
node bin/index.ts --directory /tmp/demo --name demo --scope @demo --apiPort 3000 --webPort 5173
|
|
73
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/index.ts
|
|
4
|
+
import { runTemplateCLI } from "bingo";
|
|
5
|
+
|
|
6
|
+
// src/template.ts
|
|
7
|
+
import path2 from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
// ../template-kit/src/index.ts
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
var RENAME = {
|
|
14
|
+
_gitignore: ".gitignore",
|
|
15
|
+
_dockerignore: ".dockerignore",
|
|
16
|
+
"_env.example": ".env.example"
|
|
17
|
+
};
|
|
18
|
+
function toScope(name) {
|
|
19
|
+
const n = name.trim();
|
|
20
|
+
return n.startsWith("@") ? n : `@${n}`;
|
|
21
|
+
}
|
|
22
|
+
function readTree(dir, transform, base = dir) {
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
const abs = path.join(dir, entry.name);
|
|
26
|
+
const name = RENAME[entry.name] ?? entry.name;
|
|
27
|
+
if (entry.isDirectory()) {
|
|
28
|
+
out[name] = readTree(abs, transform, base);
|
|
29
|
+
} else {
|
|
30
|
+
const rel = path.relative(base, abs);
|
|
31
|
+
out[name] = transform(rel, fs.readFileSync(abs, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function patchJson(tree, relPath, mutate) {
|
|
37
|
+
const parts = relPath.split("/");
|
|
38
|
+
let node = tree;
|
|
39
|
+
for (let i = 0; i < parts.length - 1; i++) node = node[parts[i]];
|
|
40
|
+
const key = parts[parts.length - 1];
|
|
41
|
+
const json = JSON.parse(node[key]);
|
|
42
|
+
mutate(json);
|
|
43
|
+
node[key] = `${JSON.stringify(json, null, 2)}
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/template.ts
|
|
48
|
+
import { createTemplate } from "bingo";
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
|
|
51
|
+
// package.json
|
|
52
|
+
var package_default = {
|
|
53
|
+
name: "@pauldvlp/vp-react-ts-nestjs",
|
|
54
|
+
version: "0.1.0",
|
|
55
|
+
description: "Vite+ monorepo template: a React web app + a NestJS api (conformed to the Vite+ toolchain) sharing Zod contracts.",
|
|
56
|
+
author: "pauldvlp (https://github.com/pauldvlp/vp-templates)",
|
|
57
|
+
license: "MIT",
|
|
58
|
+
homepage: "https://github.com/pauldvlp/vp-templates",
|
|
59
|
+
repository: {
|
|
60
|
+
type: "git",
|
|
61
|
+
url: "git+https://github.com/pauldvlp/vp-templates.git",
|
|
62
|
+
directory: "packages/vp-react-ts-nestjs"
|
|
63
|
+
},
|
|
64
|
+
bugs: "https://github.com/pauldvlp/vp-templates/issues",
|
|
65
|
+
keywords: [
|
|
66
|
+
"vite-plus-generator",
|
|
67
|
+
"vite-plus",
|
|
68
|
+
"nestjs",
|
|
69
|
+
"react",
|
|
70
|
+
"template"
|
|
71
|
+
],
|
|
72
|
+
bin: "./dist/index.js",
|
|
73
|
+
type: "module",
|
|
74
|
+
files: [
|
|
75
|
+
"dist",
|
|
76
|
+
"template"
|
|
77
|
+
],
|
|
78
|
+
scripts: {
|
|
79
|
+
build: "esbuild bin/index.ts --bundle --outfile=dist/index.js --format=esm --platform=node --target=node22 --packages=external",
|
|
80
|
+
dev: "node bin/index.ts",
|
|
81
|
+
prepack: "pnpm run build"
|
|
82
|
+
},
|
|
83
|
+
dependencies: {
|
|
84
|
+
bingo: "^0.9.3",
|
|
85
|
+
zod: "^3.25.76"
|
|
86
|
+
},
|
|
87
|
+
devDependencies: {
|
|
88
|
+
"@pauldvlp/template-kit": "workspace:*",
|
|
89
|
+
"@types/node": "^24",
|
|
90
|
+
esbuild: "^0.25.0",
|
|
91
|
+
typescript: "^5"
|
|
92
|
+
},
|
|
93
|
+
engines: {
|
|
94
|
+
node: ">=22.18.0"
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// src/template.ts
|
|
99
|
+
var TEMPLATE_DIR = path2.join(path2.dirname(fileURLToPath(import.meta.url)), "..", "template");
|
|
100
|
+
var MAIN_TS = path2.join("apps", "api", "src", "main.ts");
|
|
101
|
+
var SWAGGER_IMPORT = "import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'";
|
|
102
|
+
var APP_MODULE = path2.join("apps", "api", "src", "app.module.ts");
|
|
103
|
+
var SERVEWEB_MODULE = [
|
|
104
|
+
" ServeStaticModule.forRoot({",
|
|
105
|
+
" // Serve the built web app. `import.meta.dirname` resolves the same for src/main.ts (dev) and",
|
|
106
|
+
" // dist/main.js (prod) \u2014 both sit two levels under apps/api, so ../../web/dist is apps/web/dist.",
|
|
107
|
+
" rootPath: join(import.meta.dirname, '..', '..', 'web', 'dist'),",
|
|
108
|
+
" exclude: ['/api/*path']",
|
|
109
|
+
" }),"
|
|
110
|
+
].join("\n");
|
|
111
|
+
function applyDocBlock(text, tag, keep) {
|
|
112
|
+
return keep ? text.replace(new RegExp(`^<!-- ${tag}:(START|END) -->\\n`, "gm"), "") : text.replace(new RegExp(`<!-- ${tag}:START -->\\n[\\s\\S]*?<!-- ${tag}:END -->\\n`, "g"), "");
|
|
113
|
+
}
|
|
114
|
+
var template_default = createTemplate({
|
|
115
|
+
about: {
|
|
116
|
+
name: package_default.name,
|
|
117
|
+
description: package_default.description
|
|
118
|
+
},
|
|
119
|
+
options: {
|
|
120
|
+
name: z.string().describe("Root project / package name").default("my-app"),
|
|
121
|
+
scope: z.string().describe("npm scope for workspace packages, e.g. @acme (defaults to @<name>)"),
|
|
122
|
+
// Ports are strings, not z.number(): Bingo's clack prompt renders an option's default as a text
|
|
123
|
+
// placeholder and calls `.slice` on it, which throws for non-string defaults. They're only ever
|
|
124
|
+
// substituted into config files as text anyway.
|
|
125
|
+
apiPort: z.string().describe("Port the NestJS api listens on").default("3000"),
|
|
126
|
+
webPort: z.string().describe("Port the web dev server listens on").default("5173"),
|
|
127
|
+
swagger: z.boolean().describe("Expose Swagger UI at /docs on the api").default(false),
|
|
128
|
+
serveWeb: z.boolean().describe("Have the api serve the built web app (single deployable)").default(false),
|
|
129
|
+
docker: z.boolean().describe("Add a multi-stage Dockerfile for the api").default(false),
|
|
130
|
+
install: z.boolean().describe("Install deps after scaffolding").default(true)
|
|
131
|
+
},
|
|
132
|
+
// Lazily default the package scope to `@<name>` (prefixing `@` unless the name already has one),
|
|
133
|
+
// so it tracks the project name instead of a fixed value. Falls back to `@app` when no name is set.
|
|
134
|
+
prepare({ options }) {
|
|
135
|
+
return {
|
|
136
|
+
scope: () => options.name ? toScope(options.name) : "@app"
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
async produce({ options }) {
|
|
140
|
+
const scope = toScope(options.scope || options.name || "app");
|
|
141
|
+
const swaggerSetup = [
|
|
142
|
+
`const swaggerConfig = new DocumentBuilder().setTitle('${options.name} API').setVersion('1.0').build()`,
|
|
143
|
+
`SwaggerModule.setup('docs', app, SwaggerModule.createDocument(app, swaggerConfig), { jsonDocumentUrl: 'docs.json' })`
|
|
144
|
+
].join("\n ");
|
|
145
|
+
const files = readTree(TEMPLATE_DIR, (rel, content) => {
|
|
146
|
+
let out = content.split("@app").join(scope).split("__PROJECT_NAME__").join(options.name).split("__API_PORT__").join(options.apiPort).split("__WEB_PORT__").join(options.webPort);
|
|
147
|
+
if (rel === MAIN_TS) {
|
|
148
|
+
out = options.swagger ? out.replace("// __SWAGGER_IMPORT__", SWAGGER_IMPORT).replace("// __SWAGGER_SETUP__", swaggerSetup) : out.replace(/^[ \t]*\/\/ __SWAGGER_(IMPORT|SETUP)__\n/gm, "");
|
|
149
|
+
}
|
|
150
|
+
if (rel === APP_MODULE) {
|
|
151
|
+
out = options.serveWeb ? out.replace("// __SERVEWEB_PATH_IMPORT__", "import { join } from 'node:path'\n").replace("// __SERVEWEB_MODULE_IMPORT__", "import { ServeStaticModule } from '@nestjs/serve-static'").replace(" // __SERVEWEB_MODULE__", SERVEWEB_MODULE) : out.replace(/^[ \t]*\/\/ __SERVEWEB_[A-Z_]+__\n/gm, "");
|
|
152
|
+
}
|
|
153
|
+
if (rel === "README.md") {
|
|
154
|
+
out = applyDocBlock(out, "SWAGGER", options.swagger);
|
|
155
|
+
out = applyDocBlock(out, "SERVEWEB", options.serveWeb);
|
|
156
|
+
out = applyDocBlock(out, "DOCKER", options.docker);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
});
|
|
160
|
+
if (options.swagger || options.serveWeb) {
|
|
161
|
+
patchJson(files, "apps/api/package.json", (pkg) => {
|
|
162
|
+
const deps = { ...pkg.dependencies };
|
|
163
|
+
if (options.swagger) deps["@nestjs/swagger"] = "^11";
|
|
164
|
+
if (options.serveWeb) deps["@nestjs/serve-static"] = "^5";
|
|
165
|
+
pkg.dependencies = Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a.localeCompare(b)));
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (!options.docker) {
|
|
169
|
+
delete files[".dockerignore"];
|
|
170
|
+
delete files.apps.api["Dockerfile"];
|
|
171
|
+
}
|
|
172
|
+
const scripts = options.install ? [{ commands: ["pnpm install --silent"], phase: 0 }] : [];
|
|
173
|
+
return {
|
|
174
|
+
files,
|
|
175
|
+
scripts,
|
|
176
|
+
suggestions: [
|
|
177
|
+
`cd into the project and run \`vp run -r dev\` to start web + api together.`,
|
|
178
|
+
`The web app proxies \`/api\` to the NestJS server on port ${options.apiPort}.`,
|
|
179
|
+
...options.swagger ? [`Swagger UI: http://localhost:${options.apiPort}/docs (OpenAPI JSON at /docs.json).`] : [],
|
|
180
|
+
...options.serveWeb ? [`--serveWeb is on: after \`vp run -r build\`, \`node apps/api/dist/main.js\` serves the web app from the api.`] : [],
|
|
181
|
+
options.install ? `Run \`vp check\` to lint, format and type-check the whole workspace.` : `Skipped install. Run \`vp install\`, then \`vp run -r dev\`.`
|
|
182
|
+
]
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// bin/index.ts
|
|
188
|
+
process.exitCode = await runTemplateCLI(template_default);
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pauldvlp/vp-react-ts-nestjs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Vite+ monorepo template: a React web app + a NestJS api (conformed to the Vite+ toolchain) sharing Zod contracts.",
|
|
5
|
+
"author": "pauldvlp (https://github.com/pauldvlp/vp-templates)",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/pauldvlp/vp-templates",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/pauldvlp/vp-templates.git",
|
|
11
|
+
"directory": "packages/vp-react-ts-nestjs"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/pauldvlp/vp-templates/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"vite-plus-generator",
|
|
16
|
+
"vite-plus",
|
|
17
|
+
"nestjs",
|
|
18
|
+
"react",
|
|
19
|
+
"template"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"template"
|
|
25
|
+
],
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"bingo": "^0.9.3",
|
|
28
|
+
"zod": "^3.25.76"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^24",
|
|
32
|
+
"esbuild": "^0.25.0",
|
|
33
|
+
"typescript": "^5",
|
|
34
|
+
"@pauldvlp/template-kit": "0.0.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.18.0"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "esbuild bin/index.ts --bundle --outfile=dist/index.js --format=esm --platform=node --target=node22 --packages=external",
|
|
41
|
+
"dev": "node bin/index.ts"
|
|
42
|
+
},
|
|
43
|
+
"bin": {
|
|
44
|
+
"vp-react-ts-nestjs": "./dist/index.js"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
Monorepo full-stack basado en [Vite+](https://viteplus.dev): **una app web (React)** + un **api (NestJS)** que comparten **contratos Zod**.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
.
|
|
7
|
+
├── apps/
|
|
8
|
+
│ ├── web # @app/web — React + Vite+, proxea /api al backend
|
|
9
|
+
│ └── api # @app/api — NestJS, conformado a la toolchain de Vite+
|
|
10
|
+
└── packages/
|
|
11
|
+
└── contracts # @app/contracts — schemas Zod + tipos compartidos (única fuente de verdad)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Requisitos
|
|
15
|
+
|
|
16
|
+
- Node `>=22.18.0`
|
|
17
|
+
- pnpm `11.9.0` (se descarga solo vía `devEngines`)
|
|
18
|
+
- CLI `vp` (Vite+) instalado globalmente
|
|
19
|
+
|
|
20
|
+
## Empezar
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
vp install # si aún no se instaló al crear el proyecto
|
|
24
|
+
vp run -r dev # levanta web + api juntos
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- Web: <http://localhost:__WEB_PORT__>
|
|
28
|
+
- API: <http://localhost:__API_PORT__/api> (p.ej. `/api/health`, `/api/items`)
|
|
29
|
+
|
|
30
|
+
El front llama a `/api/*` y Vite+ lo **proxea** al api en dev (mismo origen, sin CORS).
|
|
31
|
+
|
|
32
|
+
## Scripts (raíz)
|
|
33
|
+
|
|
34
|
+
| Script | Qué hace |
|
|
35
|
+
| ----------------- | ------------------------------------------------- |
|
|
36
|
+
| `vp run -r dev` | Levanta web + api a la vez |
|
|
37
|
+
| `vp check` | Formatea, lintea y type-checkea todo el workspace |
|
|
38
|
+
| `vp run -r build` | Buildea todos los paquetes |
|
|
39
|
+
| `vp test` | Corre los tests (vite-plus/test) de todo el workspace |
|
|
40
|
+
| `pnpm ready` | `vp check` + build (pensado para CI) |
|
|
41
|
+
|
|
42
|
+
## Cómo está cableado el `api` (NestJS sobre Vite+)
|
|
43
|
+
|
|
44
|
+
NestJS necesita `emitDecoratorMetadata` para su inyección de dependencias. El transform **Oxc** de
|
|
45
|
+
vite-plus lo emite de forma nativa (a partir de `experimentalDecorators` + `emitDecoratorMetadata`
|
|
46
|
+
en `tsconfig.json`), así que el `api` es un paquete vite-plus normal, sin plugins de transform:
|
|
47
|
+
|
|
48
|
+
- **`vp run @app/api#dev`** → `vite-node --watch src/main.ts` (reinicio limpio en cada cambio: el
|
|
49
|
+
bloque `import.meta.hot.dispose` en `main.ts` cierra el server antes de re-ejecutar).
|
|
50
|
+
- **`vp build`** → build **SSR** de Vite que externaliza `node_modules` y emite `dist/main.js`
|
|
51
|
+
(arráncalo con `node dist/main.js` o `vp run @app/api#start`).
|
|
52
|
+
- **`vp check` / `vp test`** → incluyen el `api` automáticamente; los specs (`*.spec.ts`) corren por
|
|
53
|
+
el mismo pipeline, así que la DI de Nest funciona en los tests (ver `items.service.spec.ts`).
|
|
54
|
+
|
|
55
|
+
## Contratos compartidos (`@app/contracts`)
|
|
56
|
+
|
|
57
|
+
Los schemas Zod viven en `packages/contracts` y se importan desde ambos lados:
|
|
58
|
+
|
|
59
|
+
- El **api** valida los bodies con un `ZodValidationPipe` propio y mínimo (`apps/api/src/common`).
|
|
60
|
+
- La **web** tipa sus `fetch` con los tipos inferidos (`Item`, `CreateItem`).
|
|
61
|
+
|
|
62
|
+
## Configuración y logs
|
|
63
|
+
|
|
64
|
+
- **Env**: `apps/api/src/config/env.ts` valida `process.env` con Zod al arrancar (falla rápido).
|
|
65
|
+
Copia `apps/api/.env.example` a `.env`.
|
|
66
|
+
- **Logs**: [`nestjs-pino`](https://github.com/iamolegga/nestjs-pino). En dev se imprimen legibles
|
|
67
|
+
(vía `pino-pretty`, una línea) en la misma terminal que la web; en producción salen como JSON.
|
|
68
|
+
|
|
69
|
+
<!-- SWAGGER:START -->
|
|
70
|
+
## Swagger
|
|
71
|
+
|
|
72
|
+
La documentación interactiva (Swagger UI) se sirve en <http://localhost:__API_PORT__/docs> y el
|
|
73
|
+
documento OpenAPI en crudo en `/docs.json`. Con el api levantado (`vp run -r dev`), compruébalo:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:__API_PORT__/docs # 200
|
|
77
|
+
curl -s http://localhost:__API_PORT__/docs.json | head -c 200 # JSON OpenAPI
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
La configuración vive en `apps/api/src/main.ts` (`DocumentBuilder` + `SwaggerModule.setup`). Para
|
|
81
|
+
que un endpoint aparezca documentado con su esquema, anótalo con los decoradores de `@nestjs/swagger`
|
|
82
|
+
(`@ApiTags`, `@ApiResponse`, …).
|
|
83
|
+
|
|
84
|
+
<!-- SWAGGER:END -->
|
|
85
|
+
<!-- SERVEWEB:START -->
|
|
86
|
+
## El api sirve el web (single deployable)
|
|
87
|
+
|
|
88
|
+
Con `--serveWeb`, el api monta `apps/web/dist` con `@nestjs/serve-static` (excluyendo `/api/*`), así
|
|
89
|
+
que un solo proceso sirve front + API. Es para **producción** (en dev se usa el proxy). Compruébalo:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
vp run -r build # genera apps/web/dist y apps/api/dist
|
|
93
|
+
node apps/api/dist/main.js # o: vp run @app/api#start
|
|
94
|
+
# el api sirve el front y la API en el mismo puerto:
|
|
95
|
+
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:__API_PORT__/ # 200 (index.html)
|
|
96
|
+
curl -s http://localhost:__API_PORT__/api/health # {"status":"ok"}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
La ruta del estático se resuelve en `apps/api/src/app.module.ts` (`ServeStaticModule.forRoot`).
|
|
100
|
+
|
|
101
|
+
<!-- SERVEWEB:END -->
|
|
102
|
+
<!-- DOCKER:START -->
|
|
103
|
+
## Docker
|
|
104
|
+
|
|
105
|
+
El `api` trae un `Dockerfile` multi-stage (`apps/api/Dockerfile`). El **contexto de build debe ser la
|
|
106
|
+
raíz del monorepo** (el api se construye desde todo el workspace):
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
docker build -f apps/api/Dockerfile -t __PROJECT_NAME__-api .
|
|
110
|
+
docker run --rm -p __API_PORT__:__API_PORT__ -e PORT=__API_PORT__ __PROJECT_NAME__-api
|
|
111
|
+
# en otra terminal, comprueba:
|
|
112
|
+
curl -s http://localhost:__API_PORT__/api/health # {"status":"ok"}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Es un punto de partida (la imagen incluye el workspace completo). Para una imagen más liviana, parte
|
|
116
|
+
de `pnpm deploy --filter @app/api --prod`.
|
|
117
|
+
|
|
118
|
+
<!-- DOCKER:END -->
|
|
119
|
+
## Añadir un recurso
|
|
120
|
+
|
|
121
|
+
Mira `apps/api/src/items` (controller + service + module en memoria) y `health` como plantillas, y
|
|
122
|
+
declara su contrato en `packages/contracts`.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Logs
|
|
2
|
+
logs
|
|
3
|
+
*.log
|
|
4
|
+
npm-debug.log*
|
|
5
|
+
yarn-debug.log*
|
|
6
|
+
yarn-error.log*
|
|
7
|
+
pnpm-debug.log*
|
|
8
|
+
lerna-debug.log*
|
|
9
|
+
|
|
10
|
+
node_modules
|
|
11
|
+
dist
|
|
12
|
+
dist-ssr
|
|
13
|
+
build
|
|
14
|
+
*.local
|
|
15
|
+
|
|
16
|
+
# Env
|
|
17
|
+
.env
|
|
18
|
+
.env.*
|
|
19
|
+
!.env.example
|
|
20
|
+
|
|
21
|
+
# Editor directories and files
|
|
22
|
+
.vscode/*
|
|
23
|
+
!.vscode/extensions.json
|
|
24
|
+
.idea
|
|
25
|
+
.DS_Store
|
|
26
|
+
*.suo
|
|
27
|
+
*.ntvs*
|
|
28
|
+
*.njsproj
|
|
29
|
+
*.sln
|
|
30
|
+
*.sw?
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Multi-stage image for @app/api. The build context must be the MONOREPO ROOT (the api is built from
|
|
2
|
+
# the whole workspace), e.g. from the repo root:
|
|
3
|
+
#
|
|
4
|
+
# docker build -f apps/api/Dockerfile -t app-api .
|
|
5
|
+
#
|
|
6
|
+
# This is a pragmatic starting point — it ships the full installed workspace. For a slimmer image,
|
|
7
|
+
# look at `pnpm deploy --filter @app/api --prod`.
|
|
8
|
+
FROM node:22-slim AS build
|
|
9
|
+
# CI=true: pnpm purges a stale modules dir non-interactively instead of aborting (no TTY in a build).
|
|
10
|
+
# The prompt-disable lets corepack fetch pnpm without asking.
|
|
11
|
+
ENV CI=true
|
|
12
|
+
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
|
13
|
+
# vite-plus's native core initializes an HTTPS client on startup and panics without system CA certs,
|
|
14
|
+
# which the -slim image omits.
|
|
15
|
+
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
16
|
+
RUN corepack enable
|
|
17
|
+
WORKDIR /repo
|
|
18
|
+
COPY . .
|
|
19
|
+
# --ignore-scripts skips the `prepare` git-hooks lifecycle script (added when you opt into pre-commit
|
|
20
|
+
# hooks at `vp create`), which needs git/a repo and is pointless in an image. The build needs no
|
|
21
|
+
# install scripts.
|
|
22
|
+
RUN pnpm install --frozen-lockfile --ignore-scripts
|
|
23
|
+
# Build the whole workspace so apps/web/dist exists too (needed when the api serves the web app).
|
|
24
|
+
RUN pnpm -r run build
|
|
25
|
+
|
|
26
|
+
FROM node:22-slim AS runtime
|
|
27
|
+
ENV NODE_ENV=production
|
|
28
|
+
WORKDIR /repo
|
|
29
|
+
# The api externalizes its dependencies in the SSR build, so node_modules must be present at runtime.
|
|
30
|
+
COPY --from=build /repo ./
|
|
31
|
+
EXPOSE __API_PORT__
|
|
32
|
+
CMD ["node", "apps/api/dist/main.js"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@app/api",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite-node --watch src/main.ts",
|
|
8
|
+
"build": "vp build",
|
|
9
|
+
"start": "node dist/main.js",
|
|
10
|
+
"lint": "vp lint",
|
|
11
|
+
"check": "vp check"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@app/contracts": "workspace:*",
|
|
15
|
+
"@nestjs/common": "^11",
|
|
16
|
+
"@nestjs/core": "^11",
|
|
17
|
+
"@nestjs/platform-express": "^11",
|
|
18
|
+
"nestjs-pino": "^4",
|
|
19
|
+
"pino-http": "^10",
|
|
20
|
+
"reflect-metadata": "^0.2",
|
|
21
|
+
"rxjs": "^7.8",
|
|
22
|
+
"zod": "^3.25.76"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@nestjs/testing": "^11",
|
|
26
|
+
"@types/node": "catalog:",
|
|
27
|
+
"pino-pretty": "^13",
|
|
28
|
+
"typescript": "catalog:",
|
|
29
|
+
"vite": "catalog:",
|
|
30
|
+
"vite-node": "^6",
|
|
31
|
+
"vite-plus": "catalog:"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// __SERVEWEB_PATH_IMPORT__
|
|
2
|
+
import { Module } from '@nestjs/common'
|
|
3
|
+
// __SERVEWEB_MODULE_IMPORT__
|
|
4
|
+
import { LoggerModule } from 'nestjs-pino'
|
|
5
|
+
|
|
6
|
+
import { ConfigModule } from './config/config.module'
|
|
7
|
+
import { ENV, type Env } from './config/env'
|
|
8
|
+
import { HealthModule } from './health/health.module'
|
|
9
|
+
import { ItemsModule } from './items/items.module'
|
|
10
|
+
|
|
11
|
+
@Module({
|
|
12
|
+
imports: [
|
|
13
|
+
// __SERVEWEB_MODULE__
|
|
14
|
+
ConfigModule,
|
|
15
|
+
LoggerModule.forRootAsync({
|
|
16
|
+
// Read NODE_ENV from the Zod-validated env (the global ConfigModule) instead of raw process.env.
|
|
17
|
+
inject: [ENV],
|
|
18
|
+
useFactory: (env: Env) => ({
|
|
19
|
+
pinoHttp: {
|
|
20
|
+
// pino emits JSON by default. In dev, pretty-print it (single line, colored) so the logs are
|
|
21
|
+
// readable in the same terminal as the web dev server. In production, emit raw JSON to stdout.
|
|
22
|
+
transport: env.NODE_ENV === 'production' ? undefined : { target: 'pino-pretty', options: { singleLine: true } }
|
|
23
|
+
},
|
|
24
|
+
// Named wildcard (`*path`) instead of the legacy `*`: with `setGlobalPrefix('api')` this middleware
|
|
25
|
+
// route becomes `/api/*path`, which Express 5 / path-to-regexp 8 accepts without a deprecation warning.
|
|
26
|
+
forRoutes: ['*path']
|
|
27
|
+
})
|
|
28
|
+
}),
|
|
29
|
+
HealthModule,
|
|
30
|
+
ItemsModule
|
|
31
|
+
]
|
|
32
|
+
})
|
|
33
|
+
export class AppModule {}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { BadRequestException, type PipeTransform } from '@nestjs/common'
|
|
2
|
+
import type { ZodSchema } from 'zod'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Minimal, zero-dependency bridge between Zod and Nest's pipe system. Use it per-route against a
|
|
6
|
+
* schema from `@app/contracts`, e.g. `@Body(new ZodValidationPipe(createItemSchema)) body: CreateItem`.
|
|
7
|
+
*/
|
|
8
|
+
export class ZodValidationPipe<T> implements PipeTransform {
|
|
9
|
+
constructor(private readonly schema: ZodSchema<T>) {}
|
|
10
|
+
|
|
11
|
+
transform(value: unknown): T {
|
|
12
|
+
const result = this.schema.safeParse(value)
|
|
13
|
+
if (!result.success) throw new BadRequestException(result.error.flatten())
|
|
14
|
+
return result.data
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common'
|
|
2
|
+
|
|
3
|
+
import { ENV, loadEnv } from './env'
|
|
4
|
+
|
|
5
|
+
// Global so any provider can inject the validated env via `@Inject(ENV)`.
|
|
6
|
+
@Global()
|
|
7
|
+
@Module({
|
|
8
|
+
providers: [{ provide: ENV, useFactory: loadEnv }],
|
|
9
|
+
exports: [ENV]
|
|
10
|
+
})
|
|
11
|
+
export class ConfigModule {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
/** Validate `process.env` at boot — the app fails fast with a clear error if something is missing. */
|
|
4
|
+
export const envSchema = z.object({
|
|
5
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
6
|
+
PORT: z.coerce.number().default(__API_PORT__)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export type Env = z.infer<typeof envSchema>
|
|
10
|
+
|
|
11
|
+
/** DI token for the parsed, validated environment. */
|
|
12
|
+
export const ENV = Symbol('ENV')
|
|
13
|
+
|
|
14
|
+
export const loadEnv = (): Env => envSchema.parse(process.env)
|