start-vibing-stacks 2.19.0 → 2.20.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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.19.0",
3
+ "version": "2.20.0",
4
4
  "description": "AI-powered multi-stack dev workflow for Claude Code. Supports PHP, Node.js, Python and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,12 +24,19 @@ vendor/bin/php-cs-fixer fix --dry-run # Code style
24
24
 
25
25
  ### Node.js Gates
26
26
  ```bash
27
- bun run typecheck # TypeScript errors
28
- bun run lint # ESLint
29
- bun run test # Vitest
30
- bun run build # Build verification
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
+ bun run build # Build verification (must come AFTER route-slugs)
31
32
  ```
32
33
 
34
+ > **Next.js note.** `next build` does NOT validate dynamic-segment slug
35
+ > consistency (e.g. `[id]` and `[userId]` under the same parent). The
36
+ > `check-route-slugs.mjs` script must run **before** `build` to catch this
37
+ > statically — see `nextjs-app-router` skill, section "Dynamic Route Slug
38
+ > Consistency".
39
+
33
40
  ## Gate Results
34
41
 
35
42
  | Result | Action |
@@ -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,6 +1,6 @@
1
1
  ---
2
2
  name: nextjs-app-router
3
- version: 1.0.0
3
+ version: 1.1.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,152 @@ 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
+
109
326
  ## Metadata
110
327
 
111
328
  ```tsx
@@ -227,3 +444,7 @@ export async function createCheckout(priceId: string) {
227
444
  6. **`NEXT_PUBLIC_` with API keys, secrets, or tokens** — secrets leak to browser bundle
228
445
  7. **Calling external APIs from client components** — use Route Handlers as proxy
229
446
  8. **`process.env['SECRET']` in `'use client'` files** — only `NEXT_PUBLIC_*` vars work client-side
447
+ 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")
448
+ 10. **Webhook business logic inline in the Route Handler** — ack 2xx fast, process async (see "Webhook Handler — Critical Path")
449
+ 11. **Skipping signature verification or parsing JSON before verifying** — always verify the raw body first
450
+ 12. **Returning 5xx from a webhook on a downstream failure** — triggers provider retry storms; ack 2xx and retry from your side
@@ -20,7 +20,8 @@
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": "Build", "command": "bun run build", "required": true, "order": 4 }
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": "Build", "command": "bun run build", "required": true, "order": 5 }
24
25
  ],
25
26
  "frameworks": [
26
27
  {
@@ -35,6 +35,17 @@ 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
+
38
49
  - name: Build
39
50
  run: bun run build
40
51