start-vibing-stacks 2.19.0 → 2.21.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/dist/setup.js +11 -0
- package/package.json +1 -1
- package/stacks/_shared/skills/quality-gate/SKILL.md +20 -4
- package/stacks/nodejs/scripts/check-build-scripts.mjs +351 -0
- package/stacks/nodejs/scripts/check-route-slugs.mjs +130 -0
- package/stacks/nodejs/skills/bun-runtime/SKILL.md +21 -1
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +365 -1
- package/stacks/nodejs/stack.json +3 -1
- package/stacks/nodejs/workflows/ci.yml +22 -0
package/dist/setup.js
CHANGED
|
@@ -187,6 +187,17 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
187
187
|
spinner.text = 'Installed CI workflow templates';
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
|
+
// 11e. Copy stack-level helper scripts (e.g. nodejs/scripts/check-route-slugs.mjs)
|
|
191
|
+
// copyDirRecursive is non-destructive by default: existing files in the target
|
|
192
|
+
// project's scripts/ dir are preserved unless --force is passed.
|
|
193
|
+
const stackScriptsDir = join(PACKAGE_ROOT, 'stacks', config.stack, 'scripts');
|
|
194
|
+
if (existsSync(stackScriptsDir)) {
|
|
195
|
+
const projectScriptsDir = join(projectDir, 'scripts');
|
|
196
|
+
const copied = copyDirRecursive(stackScriptsDir, projectScriptsDir, options.force);
|
|
197
|
+
if (copied > 0) {
|
|
198
|
+
spinner.text = `Installed ${copied} stack helper script(s) to scripts/`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
190
201
|
// 12. Copy commands
|
|
191
202
|
const sharedCommandsDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'commands');
|
|
192
203
|
if (existsSync(sharedCommandsDir)) {
|
package/package.json
CHANGED
|
@@ -24,12 +24,28 @@ vendor/bin/php-cs-fixer fix --dry-run # Code style
|
|
|
24
24
|
|
|
25
25
|
### Node.js Gates
|
|
26
26
|
```bash
|
|
27
|
-
bun run typecheck
|
|
28
|
-
bun run lint
|
|
29
|
-
bun run test
|
|
30
|
-
|
|
27
|
+
bun run typecheck # TypeScript errors
|
|
28
|
+
bun run lint # ESLint
|
|
29
|
+
bun run test # Vitest
|
|
30
|
+
node scripts/check-route-slugs.mjs # Next.js — only run if framework=nextjs
|
|
31
|
+
node scripts/check-build-scripts.mjs # No dev-only tools in deploy scripts
|
|
32
|
+
bun run build # Build verification (must come AFTER both checks)
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
> **Next.js note.** `next build` does NOT validate dynamic-segment slug
|
|
36
|
+
> consistency (e.g. `[id]` and `[userId]` under the same parent). The
|
|
37
|
+
> `check-route-slugs.mjs` script must run **before** `build` to catch this
|
|
38
|
+
> statically — see `nextjs-app-router` skill, section "Dynamic Route Slug
|
|
39
|
+
> Consistency".
|
|
40
|
+
|
|
41
|
+
> **Vercel/Docker deploy note.** Build environments strip `devDependencies`
|
|
42
|
+
> (`NODE_ENV=production` → `npm install --omit=dev`). Any binary called
|
|
43
|
+
> from `scripts.build` / `prebuild` / `postinstall` that's only in
|
|
44
|
+
> `devDependencies` (e.g. `tsx`, `ts-node`, `vitest`) will crash the
|
|
45
|
+
> deploy with `command not found / exit 127`. `check-build-scripts.mjs`
|
|
46
|
+
> catches this statically — see `nextjs-app-router` skill, section
|
|
47
|
+
> "Build Script Hygiene".
|
|
48
|
+
|
|
33
49
|
## Gate Results
|
|
34
50
|
|
|
35
51
|
| Result | Action |
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-build-scripts.mjs
|
|
4
|
+
*
|
|
5
|
+
* Static validator for package.json#scripts that run at deploy time.
|
|
6
|
+
*
|
|
7
|
+
* Catches the "tsx: command not found / Error: Command 'npm run build'
|
|
8
|
+
* exited with 127" class of bug, which appears on Vercel, Docker
|
|
9
|
+
* (`npm ci --omit=dev`), and any CI that runs with `NODE_ENV=production`.
|
|
10
|
+
*
|
|
11
|
+
* Rule: any binary invoked from `build`, `prebuild`, `postbuild`,
|
|
12
|
+
* `start`, `postinstall`, `prepare`, or `prepublishOnly` MUST be one of:
|
|
13
|
+
* - a Node-builtin (`node`, plain shell)
|
|
14
|
+
* - prefixed with `npx` / `bunx` / `pnpm exec`
|
|
15
|
+
* - the package's own `bin` entry
|
|
16
|
+
* - present in `dependencies` (NOT just `devDependencies`)
|
|
17
|
+
* - a plain-Node script: `node scripts/foo.mjs`
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* node scripts/check-build-scripts.mjs # auto-detect ./package.json
|
|
21
|
+
* node scripts/check-build-scripts.mjs ./pkg/dir # explicit dir
|
|
22
|
+
*
|
|
23
|
+
* Exit codes:
|
|
24
|
+
* 0 OK
|
|
25
|
+
* 1 dev-only tool referenced from a deploy-time script
|
|
26
|
+
* 2 no package.json found (skipped)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { readFile } from 'node:fs/promises';
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { resolve, join, relative } from 'node:path';
|
|
32
|
+
|
|
33
|
+
// Scripts that run during install/build on Vercel, Docker, npm publish, etc.
|
|
34
|
+
// `postinstall` runs even with `--omit=dev`, so its commands MUST resolve from prod deps.
|
|
35
|
+
const DEPLOY_TIME_SCRIPTS = [
|
|
36
|
+
'build',
|
|
37
|
+
'prebuild',
|
|
38
|
+
'postbuild',
|
|
39
|
+
'start',
|
|
40
|
+
'postinstall',
|
|
41
|
+
'prepare',
|
|
42
|
+
'prepublishOnly',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// Tools that are conventionally dev-only. If the script invokes one of these
|
|
46
|
+
// directly (without `npx`/`bunx`) AND it's not in `dependencies`, that's the bug.
|
|
47
|
+
const DEV_ONLY_TOOLS = new Set([
|
|
48
|
+
'tsx',
|
|
49
|
+
'ts-node',
|
|
50
|
+
'tsc',
|
|
51
|
+
'typescript',
|
|
52
|
+
'vitest',
|
|
53
|
+
'jest',
|
|
54
|
+
'mocha',
|
|
55
|
+
'ava',
|
|
56
|
+
'tap',
|
|
57
|
+
'eslint',
|
|
58
|
+
'prettier',
|
|
59
|
+
'biome',
|
|
60
|
+
'rome',
|
|
61
|
+
'tsup',
|
|
62
|
+
'esbuild',
|
|
63
|
+
'rollup',
|
|
64
|
+
'webpack',
|
|
65
|
+
'rspack',
|
|
66
|
+
'parcel',
|
|
67
|
+
'tailwindcss',
|
|
68
|
+
'postcss',
|
|
69
|
+
'sass',
|
|
70
|
+
'less',
|
|
71
|
+
'concurrently',
|
|
72
|
+
'nodemon',
|
|
73
|
+
'pm2',
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// Tokens that are NEVER a binary invocation (control flow, shell built-ins, etc.)
|
|
77
|
+
const NON_BINARY_TOKENS = new Set([
|
|
78
|
+
'&&',
|
|
79
|
+
'||',
|
|
80
|
+
';',
|
|
81
|
+
'|',
|
|
82
|
+
'&',
|
|
83
|
+
'>',
|
|
84
|
+
'<',
|
|
85
|
+
'>>',
|
|
86
|
+
'<<',
|
|
87
|
+
'!',
|
|
88
|
+
'$',
|
|
89
|
+
'(',
|
|
90
|
+
')',
|
|
91
|
+
'{',
|
|
92
|
+
'}',
|
|
93
|
+
'if',
|
|
94
|
+
'then',
|
|
95
|
+
'else',
|
|
96
|
+
'fi',
|
|
97
|
+
'for',
|
|
98
|
+
'do',
|
|
99
|
+
'done',
|
|
100
|
+
'while',
|
|
101
|
+
'case',
|
|
102
|
+
'esac',
|
|
103
|
+
'cd',
|
|
104
|
+
'echo',
|
|
105
|
+
'export',
|
|
106
|
+
'true',
|
|
107
|
+
'false',
|
|
108
|
+
'cat',
|
|
109
|
+
'rm',
|
|
110
|
+
'cp',
|
|
111
|
+
'mv',
|
|
112
|
+
'mkdir',
|
|
113
|
+
'test',
|
|
114
|
+
'sh',
|
|
115
|
+
'bash',
|
|
116
|
+
'zsh',
|
|
117
|
+
'set',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
// Wrapper commands that defer to a separate binary (their first arg is the actual tool,
|
|
121
|
+
// which we want to evaluate against the same rules).
|
|
122
|
+
const WRAPPERS = new Set(['npx', 'bunx', 'pnpm', 'yarn', 'cross-env', 'env']);
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Strip shell quoting and split a command line into bare tokens.
|
|
126
|
+
* Naive but adequate for typical package.json scripts.
|
|
127
|
+
*/
|
|
128
|
+
function tokenize(line) {
|
|
129
|
+
const out = [];
|
|
130
|
+
let cur = '';
|
|
131
|
+
let quote = null;
|
|
132
|
+
for (const ch of line) {
|
|
133
|
+
if (quote) {
|
|
134
|
+
if (ch === quote) quote = null;
|
|
135
|
+
else cur += ch;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === '"' || ch === "'") {
|
|
139
|
+
quote = ch;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (/\s/.test(ch)) {
|
|
143
|
+
if (cur) {
|
|
144
|
+
out.push(cur);
|
|
145
|
+
cur = '';
|
|
146
|
+
}
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
cur += ch;
|
|
150
|
+
}
|
|
151
|
+
if (cur) out.push(cur);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Walk a script command and return the list of "first-binary" tokens that
|
|
157
|
+
* would actually be invoked. Handles `&&`, `||`, `;`, and a small set of
|
|
158
|
+
* wrappers (npx, bunx, cross-env).
|
|
159
|
+
*
|
|
160
|
+
* Returns Array<{ binary: string, prefixedByWrapper: boolean }>
|
|
161
|
+
*/
|
|
162
|
+
function extractInvocations(command) {
|
|
163
|
+
const tokens = tokenize(command);
|
|
164
|
+
const invocations = [];
|
|
165
|
+
|
|
166
|
+
let expectBinary = true; // start of a sub-command
|
|
167
|
+
let wrapperPrefix = false;
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
170
|
+
const t = tokens[i];
|
|
171
|
+
|
|
172
|
+
if (t === '&&' || t === '||' || t === ';' || t === '|') {
|
|
173
|
+
expectBinary = true;
|
|
174
|
+
wrapperPrefix = false;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Skip env-var assignments like FOO=bar baz
|
|
179
|
+
if (expectBinary && /^[A-Z_][A-Z0-9_]*=/.test(t)) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!expectBinary) continue;
|
|
184
|
+
|
|
185
|
+
// Wrappers: their "real" binary is the next token
|
|
186
|
+
if (WRAPPERS.has(t)) {
|
|
187
|
+
wrapperPrefix = true;
|
|
188
|
+
// for `pnpm exec` / `pnpm run` style, eat the sub-word
|
|
189
|
+
if (t === 'pnpm' && tokens[i + 1] === 'exec') {
|
|
190
|
+
i++;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (t === 'yarn' && tokens[i + 1] === 'dlx') {
|
|
194
|
+
i++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// for `cross-env FOO=bar baz` we need to skip env assignments too
|
|
198
|
+
if (t === 'cross-env' || t === 'env') {
|
|
199
|
+
let j = i + 1;
|
|
200
|
+
while (j < tokens.length && /^[A-Z_][A-Z0-9_]*=/.test(tokens[j])) j++;
|
|
201
|
+
if (j < tokens.length) {
|
|
202
|
+
invocations.push({ binary: tokens[j], prefixedByWrapper: false });
|
|
203
|
+
}
|
|
204
|
+
i = j;
|
|
205
|
+
expectBinary = false;
|
|
206
|
+
wrapperPrefix = false;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (NON_BINARY_TOKENS.has(t)) {
|
|
213
|
+
// Don't reset expectBinary for stuff like `echo`; treat as inert
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
invocations.push({ binary: t, prefixedByWrapper: wrapperPrefix });
|
|
218
|
+
expectBinary = false;
|
|
219
|
+
wrapperPrefix = false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return invocations;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Returns true if the binary is "safe" — Node-builtin invocation, or
|
|
227
|
+
* resolves from prod dependencies (or the package's own bin).
|
|
228
|
+
*/
|
|
229
|
+
function isSafe(binary, prefixedByWrapper, ctx) {
|
|
230
|
+
if (prefixedByWrapper) return true;
|
|
231
|
+
if (binary === 'node') return true;
|
|
232
|
+
// Direct script: `./scripts/x.sh`, `node ./scripts/x.mjs` — wrapped above
|
|
233
|
+
if (binary.startsWith('./') || binary.startsWith('/')) return true;
|
|
234
|
+
// Module via `node --import`: `node scripts/foo.mjs` is already handled above
|
|
235
|
+
|
|
236
|
+
// Package's own bin
|
|
237
|
+
if (ctx.ownBins.has(binary)) return true;
|
|
238
|
+
|
|
239
|
+
// Prod dep (the package itself may install a bin)
|
|
240
|
+
if (ctx.prodDeps.has(binary)) return true;
|
|
241
|
+
|
|
242
|
+
// Subpackages of a prod dep (rare but valid: `next-bundle-analyzer` → `next` group)
|
|
243
|
+
// — handled by direct match above for typical cases.
|
|
244
|
+
|
|
245
|
+
// Some prod deps install differently-named bins (e.g. `typescript` → `tsc`).
|
|
246
|
+
// We can't statically know these without crawling node_modules, so the
|
|
247
|
+
// heuristic falls back on the DEV_ONLY_TOOLS deny-list for the well-known
|
|
248
|
+
// offenders.
|
|
249
|
+
return !DEV_ONLY_TOOLS.has(binary);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function readJSON(path) {
|
|
253
|
+
const raw = await readFile(path, 'utf8');
|
|
254
|
+
return JSON.parse(raw);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function detectRoot(args) {
|
|
258
|
+
if (args.length > 0) return resolve(args[0]);
|
|
259
|
+
return process.cwd();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function main() {
|
|
263
|
+
const args = process.argv.slice(2);
|
|
264
|
+
const root = await detectRoot(args);
|
|
265
|
+
const pkgPath = join(root, 'package.json');
|
|
266
|
+
|
|
267
|
+
if (!existsSync(pkgPath)) {
|
|
268
|
+
console.log('[build-scripts] no package.json found — skipped.');
|
|
269
|
+
process.exit(2);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pkg = await readJSON(pkgPath);
|
|
273
|
+
const scripts = pkg.scripts ?? {};
|
|
274
|
+
const prodDeps = new Set(Object.keys(pkg.dependencies ?? {}));
|
|
275
|
+
const devDeps = new Set(Object.keys(pkg.devDependencies ?? {}));
|
|
276
|
+
const ownBins = new Set(
|
|
277
|
+
typeof pkg.bin === 'string' ? [pkg.name] : Object.keys(pkg.bin ?? {})
|
|
278
|
+
);
|
|
279
|
+
const ctx = { prodDeps, devDeps, ownBins };
|
|
280
|
+
|
|
281
|
+
const findings = [];
|
|
282
|
+
|
|
283
|
+
for (const scriptName of DEPLOY_TIME_SCRIPTS) {
|
|
284
|
+
const cmd = scripts[scriptName];
|
|
285
|
+
if (!cmd) continue;
|
|
286
|
+
|
|
287
|
+
const invocations = extractInvocations(cmd);
|
|
288
|
+
for (const inv of invocations) {
|
|
289
|
+
if (isSafe(inv.binary, inv.prefixedByWrapper, ctx)) continue;
|
|
290
|
+
const inDev = devDeps.has(inv.binary);
|
|
291
|
+
findings.push({
|
|
292
|
+
script: scriptName,
|
|
293
|
+
command: cmd,
|
|
294
|
+
binary: inv.binary,
|
|
295
|
+
inDev,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (findings.length > 0) {
|
|
301
|
+
console.error('');
|
|
302
|
+
console.error(' BUILD SCRIPT HYGIENE FAILURE (deploy will crash with exit 127)');
|
|
303
|
+
console.error(' ' + '─'.repeat(60));
|
|
304
|
+
console.error('');
|
|
305
|
+
console.error(
|
|
306
|
+
' These deploy-time scripts call a binary that will not be'
|
|
307
|
+
);
|
|
308
|
+
console.error(
|
|
309
|
+
' available on Vercel/Docker (NODE_ENV=production strips devDeps).'
|
|
310
|
+
);
|
|
311
|
+
console.error('');
|
|
312
|
+
for (const f of findings) {
|
|
313
|
+
console.error(` scripts.${f.script} → ${f.command}`);
|
|
314
|
+
console.error(` missing binary: "${f.binary}"`);
|
|
315
|
+
if (f.inDev) {
|
|
316
|
+
console.error(
|
|
317
|
+
` → "${f.binary}" is in devDependencies; deploy install omits devDeps`
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
console.error(
|
|
321
|
+
` → "${f.binary}" is not in dependencies and not a known wrapper`
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
console.error('');
|
|
325
|
+
}
|
|
326
|
+
console.error(' Fix (in order of preference):');
|
|
327
|
+
console.error(
|
|
328
|
+
' 1. Convert any TS helper to .mjs and call via `node scripts/x.mjs`'
|
|
329
|
+
);
|
|
330
|
+
console.error(
|
|
331
|
+
' 2. Move the tool to `dependencies` if it is genuinely needed at runtime'
|
|
332
|
+
);
|
|
333
|
+
console.error(
|
|
334
|
+
' 3. Prefix with `npx`/`bunx` (slower, network-dependent)'
|
|
335
|
+
);
|
|
336
|
+
console.error(
|
|
337
|
+
' 4. As LAST RESORT, set Vercel `installCommand: "npm install --include=dev"`'
|
|
338
|
+
);
|
|
339
|
+
console.error('');
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const pkgRel = relative(process.cwd(), pkgPath) || 'package.json';
|
|
344
|
+
console.log(`[build-scripts] OK — no dev-only tools in deploy-time scripts (${pkgRel})`);
|
|
345
|
+
process.exit(0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
main().catch((err) => {
|
|
349
|
+
console.error('[build-scripts] script failed:', err);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-route-slugs.mjs
|
|
4
|
+
*
|
|
5
|
+
* Static validator for Next.js App Router dynamic-segment naming.
|
|
6
|
+
*
|
|
7
|
+
* Catches the "You cannot use different slug names for the same dynamic path"
|
|
8
|
+
* runtime error BEFORE it ships, because `next build` does not catch it.
|
|
9
|
+
*
|
|
10
|
+
* Rule: inside the same parent directory, every bracket-named child
|
|
11
|
+
* (`[id]`, `[...slug]`, `[[...slug]]`) MUST share the same inner identifier.
|
|
12
|
+
*
|
|
13
|
+
* Examples:
|
|
14
|
+
*
|
|
15
|
+
* OK app/users/[userId]/page.tsx
|
|
16
|
+
* app/users/[userId]/posts/page.tsx
|
|
17
|
+
*
|
|
18
|
+
* FAIL app/users/[id]/page.tsx
|
|
19
|
+
* app/users/[userId]/posts/page.tsx <-- different slug, same parent
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* node scripts/check-route-slugs.mjs # auto-detect ./app and ./src/app
|
|
23
|
+
* node scripts/check-route-slugs.mjs ./app # explicit root(s)
|
|
24
|
+
*
|
|
25
|
+
* Exit codes:
|
|
26
|
+
* 0 OK
|
|
27
|
+
* 1 conflicting slug names detected
|
|
28
|
+
* 2 no app directory found (skipped, not an error in non-Next.js repos)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
33
|
+
import { resolve, join, relative } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const BRACKET_RE = /^\[\[?\.\.\.?([A-Za-z0-9_]+)\]?\]$|^\[([A-Za-z0-9_]+)\]$/;
|
|
36
|
+
|
|
37
|
+
function extractSlugName(dirName) {
|
|
38
|
+
const m = dirName.match(BRACKET_RE);
|
|
39
|
+
if (!m) return null;
|
|
40
|
+
return m[1] ?? m[2] ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function listChildDirs(parent) {
|
|
44
|
+
const entries = await readdir(parent, { withFileTypes: true });
|
|
45
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function walk(root, conflicts) {
|
|
49
|
+
const children = await listChildDirs(root);
|
|
50
|
+
|
|
51
|
+
const bracketChildren = children
|
|
52
|
+
.map((name) => ({ name, slug: extractSlugName(name) }))
|
|
53
|
+
.filter((c) => c.slug !== null);
|
|
54
|
+
|
|
55
|
+
if (bracketChildren.length > 1) {
|
|
56
|
+
const slugs = new Set(bracketChildren.map((c) => c.slug));
|
|
57
|
+
if (slugs.size > 1) {
|
|
58
|
+
conflicts.push({
|
|
59
|
+
parent: root,
|
|
60
|
+
children: bracketChildren.map((c) => c.name),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const child of children) {
|
|
66
|
+
await walk(join(root, child), conflicts);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function detectRoots(args) {
|
|
71
|
+
if (args.length > 0) return args.map((p) => resolve(p));
|
|
72
|
+
const candidates = ['app', 'src/app'].map((p) => resolve(process.cwd(), p));
|
|
73
|
+
return candidates.filter((p) => existsSync(p));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function main() {
|
|
77
|
+
const args = process.argv.slice(2);
|
|
78
|
+
const roots = await detectRoots(args);
|
|
79
|
+
|
|
80
|
+
if (roots.length === 0) {
|
|
81
|
+
console.log('[route-slugs] no app/ or src/app/ directory found — skipped.');
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const conflicts = [];
|
|
86
|
+
for (const root of roots) {
|
|
87
|
+
const s = await stat(root).catch(() => null);
|
|
88
|
+
if (!s?.isDirectory()) continue;
|
|
89
|
+
await walk(root, conflicts);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (conflicts.length > 0) {
|
|
93
|
+
console.error('');
|
|
94
|
+
console.error(' ROUTE SLUG CONFLICT (next.js will crash at runtime)');
|
|
95
|
+
console.error(' ' + '─'.repeat(60));
|
|
96
|
+
console.error('');
|
|
97
|
+
console.error(
|
|
98
|
+
' Next.js requires that every bracket-named sibling under the SAME'
|
|
99
|
+
);
|
|
100
|
+
console.error(
|
|
101
|
+
' parent directory uses the SAME inner slug name. Mixing names'
|
|
102
|
+
);
|
|
103
|
+
console.error(
|
|
104
|
+
' produces: "You cannot use different slug names for the same'
|
|
105
|
+
);
|
|
106
|
+
console.error(' dynamic path".');
|
|
107
|
+
console.error('');
|
|
108
|
+
for (const c of conflicts) {
|
|
109
|
+
const rel = relative(process.cwd(), c.parent) || '.';
|
|
110
|
+
console.error(` in: ${rel}/`);
|
|
111
|
+
for (const child of c.children) {
|
|
112
|
+
console.error(` ├── ${child}`);
|
|
113
|
+
}
|
|
114
|
+
console.error('');
|
|
115
|
+
}
|
|
116
|
+
console.error(' Fix: pick ONE slug name per resource (e.g. [userId]) and');
|
|
117
|
+
console.error(' rename every conflicting sibling to match.');
|
|
118
|
+
console.error('');
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const scanned = roots.map((r) => relative(process.cwd(), r) || '.').join(', ');
|
|
123
|
+
console.log(`[route-slugs] OK — no slug conflicts in: ${scanned}`);
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
main().catch((err) => {
|
|
128
|
+
console.error('[route-slugs] script failed:', err);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
});
|
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bun-runtime
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.1.0
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Bun Runtime — Fast JavaScript Runtime
|
|
7
7
|
|
|
8
8
|
**ALWAYS invoke when using Bun for scripts, packages, bundling, or testing.**
|
|
9
9
|
|
|
10
|
+
## Deploy-Time Asymmetry (READ FIRST)
|
|
11
|
+
|
|
12
|
+
> Bun's local install is generous (installs ALL deps by default). Vercel /
|
|
13
|
+
> CI builds are strict (`NODE_ENV=production` → devDeps stripped). A
|
|
14
|
+
> `package.json#scripts.build` that calls `tsx`, `ts-node`, `vitest`,
|
|
15
|
+
> `eslint`, or `tsc` directly will work locally and fail at deploy with
|
|
16
|
+
> `sh: line 1: <tool>: command not found / Error: Command "npm run build"
|
|
17
|
+
> exited with 127`.
|
|
18
|
+
|
|
19
|
+
**Rule.** Anything invoked from `scripts.build` / `prebuild` /
|
|
20
|
+
`postinstall` / `prepare` must be either:
|
|
21
|
+
|
|
22
|
+
- in `dependencies` (not `devDependencies`)
|
|
23
|
+
- prefixed with `bunx` / `npx` (slower, fragile)
|
|
24
|
+
- a plain Node script: `node scripts/foo.mjs`
|
|
25
|
+
|
|
26
|
+
For one-off utilities the `.mjs` route is best — see
|
|
27
|
+
`nextjs-app-router` skill, section "Build Script Hygiene". The stack
|
|
28
|
+
ships `scripts/check-build-scripts.mjs` to catch this statically.
|
|
29
|
+
|
|
10
30
|
## Package Management
|
|
11
31
|
|
|
12
32
|
```bash
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nextjs-app-router
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.2.0
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Next.js App Router — Modern Patterns
|
|
@@ -27,6 +27,77 @@ app/
|
|
|
27
27
|
└── route.ts # API route handler
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Dynamic Route Slug Consistency (CRITICAL — silent build killer)
|
|
33
|
+
|
|
34
|
+
> **Next.js validates dynamic-segment slug names at REQUEST TIME, not at build time.** `next build` / `bun run build` **does not catch** this class of bug. It blows up the first time anyone hits the route in production with:
|
|
35
|
+
>
|
|
36
|
+
> ```
|
|
37
|
+
> Error: You cannot use different slug names for the same dynamic path ('id' !== 'userId').
|
|
38
|
+
> ```
|
|
39
|
+
|
|
40
|
+
### The Rule
|
|
41
|
+
|
|
42
|
+
Inside the **same parent directory**, every `[bracket]` child segment MUST use the **same** inner name. Pick one slug name per resource and reuse it through every nested route under it.
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
# BROKEN — same parent (app/users/), different slug names
|
|
46
|
+
app/users/[id]/page.tsx
|
|
47
|
+
app/users/[userId]/posts/page.tsx ← runtime crash
|
|
48
|
+
|
|
49
|
+
# CORRECT — one canonical slug per resource
|
|
50
|
+
app/users/[userId]/page.tsx
|
|
51
|
+
app/users/[userId]/posts/page.tsx
|
|
52
|
+
app/users/[userId]/posts/[postId]/page.tsx
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
This also applies to catch-all (`[...slug]`) and optional catch-all (`[[...slug]]`) — you may not mix a `[id]` and `[...rest]` as siblings of the same parent.
|
|
56
|
+
|
|
57
|
+
### Pre-Flight Check (run BEFORE creating any new dynamic route)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Quick visual scan — list every dynamic segment with its depth
|
|
61
|
+
find src/app app -type d -name '[[]*[]]' 2>/dev/null \
|
|
62
|
+
| awk -F/ '{print NF":"$0}' | sort
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Eyeball the output: any two paths that share a prefix up to depth `N-1` and diverge into different `[name]` at depth `N` are the bug.
|
|
66
|
+
|
|
67
|
+
### Programmatic Check (CI gate — mandatory)
|
|
68
|
+
|
|
69
|
+
The stack ships a Bun script at `scripts/check-route-slugs.mjs`. Add it to `package.json` and wire it into the quality gate **before** `build`:
|
|
70
|
+
|
|
71
|
+
```jsonc
|
|
72
|
+
{
|
|
73
|
+
"scripts": {
|
|
74
|
+
"routes:check": "bun scripts/check-route-slugs.mjs",
|
|
75
|
+
"prebuild": "bun run routes:check",
|
|
76
|
+
"build": "next build"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Why `prebuild`: makes the check unskippable for anyone running `bun run build` locally, in Vercel, or in CI. The check completes in milliseconds and exits non-zero on the first conflict, with the offending parent dir and conflicting slugs in the error message.
|
|
82
|
+
|
|
83
|
+
### When to Run
|
|
84
|
+
|
|
85
|
+
- ☑ **Before creating** any new `[something]/page.tsx` or `[something]/route.ts`
|
|
86
|
+
- ☑ **Before any commit** touching `app/**/[*]/**`
|
|
87
|
+
- ☑ **In CI** before `bun run build`
|
|
88
|
+
- ☑ **As `prebuild`** in `package.json` so local builds also catch it
|
|
89
|
+
|
|
90
|
+
### Convention — name your slugs by resource, not by position
|
|
91
|
+
|
|
92
|
+
| Resource | Slug |
|
|
93
|
+
|---|---|
|
|
94
|
+
| User | `[userId]` |
|
|
95
|
+
| Organization / tenant | `[orgId]` or `[tenantId]` |
|
|
96
|
+
| Post / article | `[postId]` |
|
|
97
|
+
| Instance ID (Evolution API, webhook key) | `[instanceKey]` (or whatever you commit to — pick once) |
|
|
98
|
+
|
|
99
|
+
Generic `[id]` is acceptable only at the root of a resource (`app/users/[id]/...`) **if and only if** you stay with `[id]` through every nested segment under it. Mixing `[id]` and `[userId]` under the same parent is the bug.
|
|
100
|
+
|
|
30
101
|
## Server vs Client Components
|
|
31
102
|
|
|
32
103
|
```tsx
|
|
@@ -106,6 +177,293 @@ export async function POST(request: NextRequest) {
|
|
|
106
177
|
}
|
|
107
178
|
```
|
|
108
179
|
|
|
180
|
+
## Webhook Handler — Critical Path (avoid retry storms)
|
|
181
|
+
|
|
182
|
+
> A webhook receiver is a **critical path you do not control**. The provider (Stripe, GitHub, Evolution, Meta, etc.) will retry — often aggressively, often forever — every non-`2xx`. Any error you let propagate becomes their problem AND yours.
|
|
183
|
+
|
|
184
|
+
### The Three Rules
|
|
185
|
+
|
|
186
|
+
1. **Verify signature with the RAW body BEFORE parsing JSON.** Parsing first leaks payload validity into your error path and can let unsigned traffic through.
|
|
187
|
+
2. **Acknowledge fast (return 2xx within ≤ 5 s).** Persist the event, hand it off to a queue / `waitUntil` / background task, then return. The HTTP handler does NOT do business logic.
|
|
188
|
+
3. **Idempotency by provider event ID.** Same event arriving twice (retries, replays) MUST be a no-op. Store the event ID with a unique index.
|
|
189
|
+
|
|
190
|
+
### Reference Receiver (Next.js Route Handler)
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// app/api/webhooks/[provider]/route.ts
|
|
194
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
195
|
+
import crypto from 'node:crypto';
|
|
196
|
+
|
|
197
|
+
export const runtime = 'nodejs'; // crypto.timingSafeEqual + raw body
|
|
198
|
+
export const dynamic = 'force-dynamic';
|
|
199
|
+
|
|
200
|
+
const SECRET = process.env['WEBHOOK_SECRET']!;
|
|
201
|
+
|
|
202
|
+
function verify(rawBody: string, signature: string | null): boolean {
|
|
203
|
+
if (!signature) return false;
|
|
204
|
+
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
|
|
205
|
+
const a = Buffer.from(signature);
|
|
206
|
+
const b = Buffer.from(expected);
|
|
207
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function POST(req: NextRequest) {
|
|
211
|
+
// 1) RAW body — never req.json() before signature check
|
|
212
|
+
const rawBody = await req.text();
|
|
213
|
+
const signature = req.headers.get('x-signature');
|
|
214
|
+
|
|
215
|
+
if (!verify(rawBody, signature)) {
|
|
216
|
+
// Signature failure is the ONLY 4xx we return. Provider will not retry 401.
|
|
217
|
+
return new NextResponse('invalid signature', { status: 401 });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 2) Parse AFTER signature passes
|
|
221
|
+
let event: { id: string; type: string; data: unknown };
|
|
222
|
+
try {
|
|
223
|
+
event = JSON.parse(rawBody);
|
|
224
|
+
} catch {
|
|
225
|
+
// Malformed body from an authenticated source = log + 200.
|
|
226
|
+
// Returning 400/500 here triggers infinite retries for a payload we cannot process anyway.
|
|
227
|
+
logger.warn({ rawBody }, 'webhook.parse_failed');
|
|
228
|
+
return new NextResponse('accepted', { status: 200 });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 3) Idempotency — store-or-skip on event.id (unique index)
|
|
232
|
+
try {
|
|
233
|
+
await db.webhookEvent.create({
|
|
234
|
+
data: { id: event.id, type: event.type, payload: event, status: 'pending' },
|
|
235
|
+
});
|
|
236
|
+
} catch (e) {
|
|
237
|
+
if (isUniqueViolation(e)) {
|
|
238
|
+
// Duplicate delivery — already accepted. Ack and move on.
|
|
239
|
+
return new NextResponse('duplicate', { status: 200 });
|
|
240
|
+
}
|
|
241
|
+
// DB down: signal the provider to retry (this IS our fault).
|
|
242
|
+
logger.error({ err: e, eventId: event.id }, 'webhook.persist_failed');
|
|
243
|
+
return new NextResponse('storage error', { status: 503 });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 4) Hand off async. NEVER await business logic here.
|
|
247
|
+
// Options, in order of preference:
|
|
248
|
+
// (a) push to a queue (BullMQ, Inngest, QStash, SQS)
|
|
249
|
+
// (b) Vercel: `waitUntil(processEvent(event))` — runs after response
|
|
250
|
+
// (c) trigger an internal API call with `fetch(..., { keepalive: true })`
|
|
251
|
+
await queue.publish('webhook.received', { eventId: event.id });
|
|
252
|
+
|
|
253
|
+
// 5) Always 2xx if we got this far. Provider stops retrying.
|
|
254
|
+
return NextResponse.json({ received: true });
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### The Async Processor (separate from the receiver)
|
|
259
|
+
|
|
260
|
+
The processor is where downstream calls happen. **It must absorb its own failures** — never let them bubble back into the HTTP receiver:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
// jobs/process-webhook.ts
|
|
264
|
+
import CircuitBreaker from 'opossum';
|
|
265
|
+
|
|
266
|
+
const downstream = new CircuitBreaker(callDownstreamAPI, {
|
|
267
|
+
timeout: 5_000,
|
|
268
|
+
errorThresholdPercentage: 50,
|
|
269
|
+
resetTimeout: 30_000,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
export async function processWebhook(eventId: string) {
|
|
273
|
+
const event = await db.webhookEvent.findUniqueOrThrow({ where: { id: eventId } });
|
|
274
|
+
if (event.status === 'processed') return; // re-entrancy safety
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
await downstream.fire(event.payload);
|
|
278
|
+
await db.webhookEvent.update({
|
|
279
|
+
where: { id: eventId },
|
|
280
|
+
data: { status: 'processed', processedAt: new Date() },
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
// Mark for retry from OUR side (queue redelivery + backoff),
|
|
284
|
+
// NOT from the provider's side. Provider already got 2xx.
|
|
285
|
+
await db.webhookEvent.update({
|
|
286
|
+
where: { id: eventId },
|
|
287
|
+
data: {
|
|
288
|
+
status: 'failed',
|
|
289
|
+
attempts: { increment: 1 },
|
|
290
|
+
lastError: serializeError(err),
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
logger.error({ err, eventId }, 'webhook.process_failed');
|
|
294
|
+
throw err; // queue will backoff + retry per OUR policy
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
See `error-handling` Pattern 5 for circuit breaker tuning and Pattern 4 for retry+backoff.
|
|
300
|
+
|
|
301
|
+
### FORBIDDEN — Webhook Handlers
|
|
302
|
+
|
|
303
|
+
| Anti-pattern | Why it's lethal |
|
|
304
|
+
|---|---|
|
|
305
|
+
| `await req.json()` before signature verification | Signature is computed on the raw body bytes; framework re-serialization breaks it. Also accepts unsigned traffic into your parser. |
|
|
306
|
+
| Doing the business logic inline in the handler | Provider timeout (≤ 5–30 s) → they retry while you're still processing → duplicate writes. |
|
|
307
|
+
| Returning `5xx` on downstream failures | Provider retries forever, queue floods, your downstream gets even more load. Ack 2xx, retry from your side. |
|
|
308
|
+
| Returning `4xx` on parse / business errors | Same retry storm. Only `4xx` justified is `401` for bad signature. |
|
|
309
|
+
| No idempotency key | First retry creates a duplicate user / duplicate charge / duplicate message. |
|
|
310
|
+
| Logging the full payload | PII / secrets in logs. Log the event ID + type; redact `data.*`. |
|
|
311
|
+
| One `/api/webhooks` for all providers | Each provider has its own signature scheme, secrets, retry policy. Isolate per-route (`/api/webhooks/[provider]`). |
|
|
312
|
+
| Trusting `X-Forwarded-For` for provider IP allowlist | Use signature verification, not IP allowlisting. Provider IPs rotate. |
|
|
313
|
+
|
|
314
|
+
### Pre-Commit Checklist (Webhook Routes)
|
|
315
|
+
|
|
316
|
+
- [ ] Signature verified on the **raw body** before any parsing
|
|
317
|
+
- [ ] `timingSafeEqual` used for signature comparison (no `===`)
|
|
318
|
+
- [ ] Provider event ID stored with a unique index → idempotency
|
|
319
|
+
- [ ] Handler returns 2xx within ~1 s on the success path
|
|
320
|
+
- [ ] Business logic delegated to queue / `waitUntil` / background task
|
|
321
|
+
- [ ] Downstream calls wrapped in circuit breaker + retry+backoff
|
|
322
|
+
- [ ] Logs include `eventId` + `provider` + `type`; payload `data` redacted
|
|
323
|
+
- [ ] Tested: duplicate delivery → 200 (no duplicate side-effect)
|
|
324
|
+
- [ ] Tested: invalid signature → 401, never reaches the parser
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Build Script Hygiene (Vercel / CI — silent deploy killer)
|
|
329
|
+
|
|
330
|
+
> **Vercel sets `NODE_ENV=production` during deploy**, which causes `npm
|
|
331
|
+
> install` to **omit `devDependencies`**. Any binary your `build` /
|
|
332
|
+
> `prebuild` / `postinstall` script invokes must be resolvable from
|
|
333
|
+
> `dependencies` (or `node_modules/.bin` shipped via a prod dep) —
|
|
334
|
+
> otherwise you get `sh: line 1: <tool>: command not found` and
|
|
335
|
+
> `Error: Command "npm run build" exited with 127` at deploy time, even
|
|
336
|
+
> though `bun run build` worked locally.
|
|
337
|
+
|
|
338
|
+
This is **not** a Next.js bug. Same trap exists on Vercel Functions,
|
|
339
|
+
Netlify, Cloudflare Pages, Railway, Render, Fly.io, AWS Amplify, Docker
|
|
340
|
+
multi-stage builds, and any CI runner that respects `NODE_ENV` or runs
|
|
341
|
+
`npm ci --omit=dev`.
|
|
342
|
+
|
|
343
|
+
### Common Offenders (devDep tools invoked from `scripts`)
|
|
344
|
+
|
|
345
|
+
| Tool | Typical wrong usage | Why it breaks |
|
|
346
|
+
|---|---|---|
|
|
347
|
+
| `tsx` | `"build": "tsx scripts/seed.ts && next build"` | `tsx` is dev-only; not installed on Vercel |
|
|
348
|
+
| `ts-node` | `"prebuild": "ts-node ./gen.ts"` | Same — `ts-node` rarely in `dependencies` |
|
|
349
|
+
| `tsc` | `"prebuild": "tsc -p tsconfig.gen.json"` | `typescript` is conventionally a devDep |
|
|
350
|
+
| `vitest` / `jest` | `"prebuild": "vitest run"` | Test runners are dev-only |
|
|
351
|
+
| `eslint` / `prettier` / `biome` | `"build": "eslint . && next build"` | Linters are dev-only |
|
|
352
|
+
| `tailwindcss` (CLI) | `"build": "tailwindcss -i ... && next build"` | Next.js handles Tailwind via its compiler; the standalone CLI is dev-only |
|
|
353
|
+
| `prisma` | `"postinstall": "prisma generate"` | **OK** only if `prisma` is in `dependencies` (it should be) — generation needs the CLI on every install |
|
|
354
|
+
|
|
355
|
+
### The Rule
|
|
356
|
+
|
|
357
|
+
Anything referenced in `scripts.build`, `scripts.prebuild`,
|
|
358
|
+
`scripts.postbuild`, `scripts.start`, `scripts.postinstall`,
|
|
359
|
+
`scripts.prepare`, `scripts.prepublishOnly` must be one of:
|
|
360
|
+
|
|
361
|
+
1. A Node-builtin (`node`, plain shell)
|
|
362
|
+
2. Prefixed with `npx` / `bunx` / `pnpm exec` (downloads on demand — slow, fragile)
|
|
363
|
+
3. The package's own `bin` entry
|
|
364
|
+
4. Present in `dependencies` (not just `devDependencies`)
|
|
365
|
+
5. A plain-Node script: `node scripts/foo.mjs` (zero deps; works everywhere)
|
|
366
|
+
|
|
367
|
+
### Fix Vectors (in order of preference)
|
|
368
|
+
|
|
369
|
+
#### 1. Convert TS scripts to zero-dep `.mjs` (BEST for tiny utilities)
|
|
370
|
+
|
|
371
|
+
```jsonc
|
|
372
|
+
// BEFORE — fails on Vercel
|
|
373
|
+
{ "scripts": { "prebuild": "tsx scripts/check-routes.ts" } }
|
|
374
|
+
|
|
375
|
+
// AFTER — works everywhere, no devDep needed
|
|
376
|
+
{ "scripts": { "prebuild": "node scripts/check-routes.mjs" } }
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Use modern Node features (`node:fs/promises`, top-level `await`,
|
|
380
|
+
`import.meta`). Bun, Node 20+, and every CI runner support `.mjs`
|
|
381
|
+
natively.
|
|
382
|
+
|
|
383
|
+
#### 2. Move the tool to `dependencies` (when you genuinely need the runtime tool)
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
# Prisma client generation — needs the CLI on every install
|
|
387
|
+
bun remove -D prisma
|
|
388
|
+
bun add prisma
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Costs: larger node_modules in prod. Acceptable for runtime-needed CLIs
|
|
392
|
+
(`prisma`, sometimes `tsx` if you have many TS scripts).
|
|
393
|
+
|
|
394
|
+
#### 3. Configure Vercel to install devDeps (LAST RESORT — global override)
|
|
395
|
+
|
|
396
|
+
```jsonc
|
|
397
|
+
// vercel.json
|
|
398
|
+
{ "installCommand": "npm install --include=dev" }
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
This **doubles** the install size for every deploy. Only use when you
|
|
402
|
+
have a real TypeScript build pipeline that can't be migrated to `.mjs`.
|
|
403
|
+
|
|
404
|
+
#### 4. Compile TS scripts ahead of time (advanced)
|
|
405
|
+
|
|
406
|
+
Bundle TS utilities to `.js` with `tsup`/`esbuild` during dev, commit
|
|
407
|
+
the output, run the `.js` in build. Useful for big script suites; for
|
|
408
|
+
one-off utilities the `.mjs` approach is simpler.
|
|
409
|
+
|
|
410
|
+
### Why "It Works on My Machine"
|
|
411
|
+
|
|
412
|
+
| Environment | Behavior |
|
|
413
|
+
|---|---|
|
|
414
|
+
| Local `bun install` | Installs ALL deps by default (including devDeps) |
|
|
415
|
+
| Local `bun run build` | `node_modules/.bin/tsx` exists → succeeds |
|
|
416
|
+
| Local `npm install` (no flags) | Installs ALL deps (devDeps included unless `NODE_ENV=production`) |
|
|
417
|
+
| Vercel build step | Runs with `NODE_ENV=production` → devDeps **stripped** → `tsx` missing |
|
|
418
|
+
| Docker `FROM node:20` + `npm ci --omit=dev` | Same as Vercel |
|
|
419
|
+
| GitHub Actions default | Installs ALL deps unless workflow explicitly sets `NODE_ENV=production` |
|
|
420
|
+
|
|
421
|
+
The asymmetry is the trap. Local dev and CI accidentally agree; deploy
|
|
422
|
+
disagrees. Catch it statically.
|
|
423
|
+
|
|
424
|
+
### Static Check (CI gate — mandatory)
|
|
425
|
+
|
|
426
|
+
The stack ships `scripts/check-build-scripts.mjs` (zero deps). It parses
|
|
427
|
+
`package.json`, walks every deploy-time script (`build`, `prebuild`,
|
|
428
|
+
`postbuild`, `start`, `postinstall`, `prepare`, `prepublishOnly`),
|
|
429
|
+
tokenises the command, and flags any token that is:
|
|
430
|
+
|
|
431
|
+
- A known dev-only tool name (`tsx`, `ts-node`, `vitest`, `eslint`, ...)
|
|
432
|
+
- AND not prefixed with `npx`/`bunx`/`pnpm exec`/`node`
|
|
433
|
+
- AND not present in `dependencies`
|
|
434
|
+
|
|
435
|
+
Wire it as `prebuild` AND in CI:
|
|
436
|
+
|
|
437
|
+
```jsonc
|
|
438
|
+
{
|
|
439
|
+
"scripts": {
|
|
440
|
+
"routes:check": "node scripts/check-route-slugs.mjs",
|
|
441
|
+
"build:check": "node scripts/check-build-scripts.mjs",
|
|
442
|
+
"prebuild": "bun run routes:check && bun run build:check",
|
|
443
|
+
"build": "next build"
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Stack-shipped Scripts MUST Be `.mjs`
|
|
449
|
+
|
|
450
|
+
When this stack scaffolds a helper script (`check-route-slugs.mjs`,
|
|
451
|
+
`check-build-scripts.mjs`, anything in `scripts/`), it is **always**
|
|
452
|
+
plain `.mjs` runnable via `node`. **Never** use `.ts` requiring `tsx`
|
|
453
|
+
or `ts-node`. The whole point of these scripts is to run during
|
|
454
|
+
build — they have to work on Vercel.
|
|
455
|
+
|
|
456
|
+
### Pre-Commit Checklist (Build Scripts)
|
|
457
|
+
|
|
458
|
+
- [ ] `package.json#scripts.build` has no `tsx` / `ts-node` / dev-only CLI
|
|
459
|
+
- [ ] `package.json#scripts.prebuild` (if any) is also clean
|
|
460
|
+
- [ ] `package.json#scripts.postinstall` (runs on Vercel during install) is also clean
|
|
461
|
+
- [ ] Any helper script in `scripts/` is `.mjs`, runnable via plain `node`
|
|
462
|
+
- [ ] If a runtime tool is genuinely needed (e.g. `prisma generate`), it lives in `dependencies`, not `devDependencies`
|
|
463
|
+
- [ ] Tested with `NODE_ENV=production npm ci && npm run build` locally before deploy
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
109
467
|
## Metadata
|
|
110
468
|
|
|
111
469
|
```tsx
|
|
@@ -227,3 +585,9 @@ export async function createCheckout(priceId: string) {
|
|
|
227
585
|
6. **`NEXT_PUBLIC_` with API keys, secrets, or tokens** — secrets leak to browser bundle
|
|
228
586
|
7. **Calling external APIs from client components** — use Route Handlers as proxy
|
|
229
587
|
8. **`process.env['SECRET']` in `'use client'` files** — only `NEXT_PUBLIC_*` vars work client-side
|
|
588
|
+
9. **Mixing `[id]` / `[userId]` / `[someId]` as siblings of the same parent dir** — runtime crash; `next build` does NOT catch it. Run `bun run routes:check` (see "Dynamic Route Slug Consistency")
|
|
589
|
+
10. **Webhook business logic inline in the Route Handler** — ack 2xx fast, process async (see "Webhook Handler — Critical Path")
|
|
590
|
+
11. **Skipping signature verification or parsing JSON before verifying** — always verify the raw body first
|
|
591
|
+
12. **Returning 5xx from a webhook on a downstream failure** — triggers provider retry storms; ack 2xx and retry from your side
|
|
592
|
+
13. **Calling `tsx` / `ts-node` / `vitest` / `eslint` directly from `scripts.build` or `scripts.prebuild`** — Vercel strips devDeps; build fails with exit 127. Convert to `.mjs` or move to `dependencies` (see "Build Script Hygiene")
|
|
593
|
+
14. **Shipping helper scripts as `.ts`** — they cannot run on Vercel without `tsx` in `dependencies`. Use `.mjs` and plain `node`
|
package/stacks/nodejs/stack.json
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
{ "name": "TypeCheck", "command": "bun run typecheck", "required": true, "order": 1 },
|
|
21
21
|
{ "name": "Lint", "command": "bun run lint", "required": true, "order": 2 },
|
|
22
22
|
{ "name": "Tests", "command": "bun run test", "required": true, "order": 3 },
|
|
23
|
-
{ "name": "
|
|
23
|
+
{ "name": "RouteSlugs", "command": "node scripts/check-route-slugs.mjs", "required": true, "order": 4, "appliesTo": ["nextjs"], "description": "Static Next.js dynamic-route slug consistency check. `next build` does not catch this class of bug." },
|
|
24
|
+
{ "name": "BuildScripts", "command": "node scripts/check-build-scripts.mjs", "required": true, "order": 5, "description": "Static check that scripts.build/prebuild/postinstall do not call dev-only binaries (tsx, ts-node, vitest, eslint, ...) — Vercel/Docker strip devDeps and the build crashes with exit 127." },
|
|
25
|
+
{ "name": "Build", "command": "bun run build", "required": true, "order": 6 }
|
|
24
26
|
],
|
|
25
27
|
"frameworks": [
|
|
26
28
|
{
|
|
@@ -35,6 +35,28 @@ jobs:
|
|
|
35
35
|
- name: Unit tests
|
|
36
36
|
run: bun run test
|
|
37
37
|
|
|
38
|
+
- name: Next.js route slug consistency
|
|
39
|
+
# `next build` does NOT catch mismatched dynamic-segment names
|
|
40
|
+
# (e.g. mixing [id] and [userId] under the same parent). This
|
|
41
|
+
# script catches it statically before build, in milliseconds.
|
|
42
|
+
# Skips with exit 2 on non-Next.js repos (treated as success here).
|
|
43
|
+
run: |
|
|
44
|
+
if [ -f scripts/check-route-slugs.mjs ]; then
|
|
45
|
+
node scripts/check-route-slugs.mjs || code=$?
|
|
46
|
+
if [ "${code:-0}" = "1" ]; then exit 1; fi
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
- name: Build-script hygiene (no devDeps in deploy scripts)
|
|
50
|
+
# Vercel/Docker strip devDependencies during install. Any binary
|
|
51
|
+
# invoked from build/prebuild/postinstall MUST be in dependencies
|
|
52
|
+
# or be a plain `node script.mjs` call. Catches the
|
|
53
|
+
# "tsx: command not found / exited 127" class of bug.
|
|
54
|
+
run: |
|
|
55
|
+
if [ -f scripts/check-build-scripts.mjs ]; then
|
|
56
|
+
node scripts/check-build-scripts.mjs || code=$?
|
|
57
|
+
if [ "${code:-0}" = "1" ]; then exit 1; fi
|
|
58
|
+
fi
|
|
59
|
+
|
|
38
60
|
- name: Build
|
|
39
61
|
run: bun run build
|
|
40
62
|
|