start-vibing-stacks 2.20.0 → 2.22.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/package.json +1 -1
- package/stacks/_shared/skills/debugging-patterns/SKILL.md +74 -1
- package/stacks/_shared/skills/quality-gate/SKILL.md +10 -1
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +140 -2
- package/stacks/nodejs/scripts/check-build-scripts.mjs +351 -0
- package/stacks/nodejs/skills/bun-runtime/SKILL.md +21 -1
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +144 -1
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +11 -0
- package/stacks/php/scripts/check-vite-manifest.mjs +309 -0
- package/stacks/php/skills/inertia-react/SKILL.md +126 -2
- package/stacks/php/stack.json +2 -1
- package/templates/CLAUDE-php.md +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: debugging-patterns
|
|
3
|
-
version: 1.
|
|
3
|
+
version: 1.1.0
|
|
4
|
+
description: Universal debugging strategies plus the "Bundle, not Backend"
|
|
5
|
+
triage for silent wrong-component renders (HTTP 200, empty server logs,
|
|
6
|
+
wrong default export shipped). Pairs with `inertia-react` "Vite Build
|
|
7
|
+
Gotchas" section.
|
|
4
8
|
---
|
|
5
9
|
|
|
6
10
|
# Debugging Patterns
|
|
@@ -34,6 +38,72 @@ node --inspect script.js
|
|
|
34
38
|
DEBUG=* node script.js
|
|
35
39
|
```
|
|
36
40
|
|
|
41
|
+
## Bundle, not Backend (silent wrong-component / wrong-output renders)
|
|
42
|
+
|
|
43
|
+
> **When the server returns HTTP 200, server logs are empty, and the browser
|
|
44
|
+
> still renders the wrong content — STOP debugging the server.** The bug is
|
|
45
|
+
> in the bundle. This is the #1 source of "Sonnet runs in circles for 90
|
|
46
|
+
> minutes" debugging sessions.
|
|
47
|
+
|
|
48
|
+
### Symptoms that point to the bundle, not the server
|
|
49
|
+
|
|
50
|
+
| Symptom | Why it implicates the bundle |
|
|
51
|
+
|---|---|
|
|
52
|
+
| HTTP 200 on every request, browser shows wrong content | Server is producing correct payload; client is misresolving it |
|
|
53
|
+
| Empty server logs (Laravel/Node/Python) | No exception was thrown; backend is genuinely correct |
|
|
54
|
+
| The wrong component/page/template is rendered | Default export of the loaded chunk is not the expected one |
|
|
55
|
+
| Reverting the last `vite.config.js` / `webpack.config.js` / `rollup.config.js` change fixes it | Build graph is the locus |
|
|
56
|
+
| Only happens after `npm run build` (dev works) | Production chunking heuristics differ from dev's per-file modules |
|
|
57
|
+
| Different routes resolve to the same JS asset URL | Resolve-map collision in `import.meta.glob` / module federation |
|
|
58
|
+
|
|
59
|
+
### Triage protocol (5 steps, ~3 minutes)
|
|
60
|
+
|
|
61
|
+
1. **Confirm the payload** — DevTools → Network → click the HTML/XHR request
|
|
62
|
+
for the broken route → inspect the response. If the server-side identifier
|
|
63
|
+
(`component:` for Inertia, route name for Next.js RSC, etc.) is the EXPECTED
|
|
64
|
+
one, the server is correct. Move to step 2.
|
|
65
|
+
2. **Find the JS chunk that loaded** — in the same Network tab, look at the
|
|
66
|
+
`.js` requests that fired after the page request. For SPA frameworks, one
|
|
67
|
+
of them is the page chunk. Note its URL/hash.
|
|
68
|
+
3. **Inspect the chunk's `default` export** — open the chunk URL in a new tab,
|
|
69
|
+
`Ctrl+F` for `default:` / `export{` / `as default}`. Identify the actual
|
|
70
|
+
component name being exported.
|
|
71
|
+
4. **Compare** — if (chunk's default export) ≠ (server's component identifier),
|
|
72
|
+
confirmed: the bug is in the bundle. Possible causes (in order of likelihood):
|
|
73
|
+
- `manualChunks` collided with an `entry` chunk (Rollup/Rolldown silently
|
|
74
|
+
dropped a group — see `inertia-react §Vite Build Gotchas`)
|
|
75
|
+
- Two files with the same default export name caused a hash reuse
|
|
76
|
+
- A barrel/re-export chain has the wrong file at the top
|
|
77
|
+
- Tree-shaking removed the expected export because it was only referenced
|
|
78
|
+
conditionally
|
|
79
|
+
5. **Bisect on the build config** — revert the last commit that touched
|
|
80
|
+
`vite.config.*`, `rollup.config.*`, `webpack.config.*`, `next.config.*`,
|
|
81
|
+
`turbo.json`, or any chunking-related setting. If the bug disappears,
|
|
82
|
+
you've located it. Do NOT debug the server.
|
|
83
|
+
|
|
84
|
+
### Common causes (multi-stack)
|
|
85
|
+
|
|
86
|
+
| Stack | Pattern | Fix reference |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| Laravel + Inertia + Vite | Same module in `laravel.input[]` AND `Pages/**` glob | `inertia-react §Vite Build Gotchas` |
|
|
89
|
+
| Next.js App Router | Server/Client component boundary mis-resolved by bundler split | Check `'use client'` placement, then `next.config.mjs` |
|
|
90
|
+
| Module Federation | Remote chunk hash mismatch after redeploy | Force remote re-fetch, check `shared` deps versions |
|
|
91
|
+
| Webpack 5 | `splitChunks.cacheGroups` overlap with `entry` | Same class of bug as Vite manualChunks |
|
|
92
|
+
| esbuild | Two CJS modules with same `module.exports.default` deduped | Set `format: 'esm'` or use named exports |
|
|
93
|
+
|
|
94
|
+
### Validators that catch the bug at build time
|
|
95
|
+
|
|
96
|
+
If a `scripts/check-vite-manifest.mjs` exists in the project (shipped by
|
|
97
|
+
`start-vibing-stacks` for PHP/Laravel projects), run it after every build:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
node scripts/check-vite-manifest.mjs
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
It cross-references `laravel.input[]` against `import.meta.glob` patterns and
|
|
104
|
+
fails the build if any module appears in both — which is the Laravel/Inertia
|
|
105
|
+
specific shape of "Bundle, not Backend."
|
|
106
|
+
|
|
37
107
|
## Anti-Patterns
|
|
38
108
|
|
|
39
109
|
| Don't | Do |
|
|
@@ -42,3 +112,6 @@ DEBUG=* node script.js
|
|
|
42
112
|
| Fix symptom, not cause | Trace to root cause |
|
|
43
113
|
| Skip writing test for fix | Always add regression test |
|
|
44
114
|
| Leave debug code in commit | Clean before commit |
|
|
115
|
+
| Debug the server when HTTP 200 + empty logs + wrong content | Triage as "Bundle, not Backend" — start from the chunk graph |
|
|
116
|
+
| Trust that `manualChunks` / `splitChunks` always honors your grouping | Bundlers silently drop groups when they collide with entry chunks |
|
|
117
|
+
| Move on after one `npm run build` succeeded | Run the manifest validator; bundlers don't warn on collision |
|
|
@@ -28,7 +28,8 @@ bun run typecheck # TypeScript errors
|
|
|
28
28
|
bun run lint # ESLint
|
|
29
29
|
bun run test # Vitest
|
|
30
30
|
node scripts/check-route-slugs.mjs # Next.js — only run if framework=nextjs
|
|
31
|
-
|
|
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)
|
|
32
33
|
```
|
|
33
34
|
|
|
34
35
|
> **Next.js note.** `next build` does NOT validate dynamic-segment slug
|
|
@@ -37,6 +38,14 @@ bun run build # Build verification (must come AFTER
|
|
|
37
38
|
> statically — see `nextjs-app-router` skill, section "Dynamic Route Slug
|
|
38
39
|
> Consistency".
|
|
39
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
|
+
|
|
40
49
|
## Gate Results
|
|
41
50
|
|
|
42
51
|
| Result | Action |
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: inertia-react
|
|
3
|
-
version: 2.
|
|
3
|
+
version: 2.1.0
|
|
4
4
|
description: LEGACY skill — Inertia.js + React frontend integration with
|
|
5
5
|
Laravel-rendered pages. Use ONLY in pre-existing Inertia projects. For NEW
|
|
6
6
|
projects use the `react-api` frontend stack (`axios-laravel-api` +
|
|
7
|
-
`react-api-standards`) which decouples render from data fetching.
|
|
7
|
+
`react-api-standards`) which decouples render from data fetching. v2.1.0 adds
|
|
8
|
+
the "Vite Build Gotchas" section covering the `manualChunks` × entry-chunk
|
|
9
|
+
× `resolvePageComponent` glob interaction that produces silent
|
|
10
|
+
wrong-component renders (production post-mortem 2026-05).
|
|
8
11
|
---
|
|
9
12
|
|
|
10
13
|
# Inertia.js + React Integration (LEGACY)
|
|
@@ -343,6 +346,138 @@ router.reload({ only: ['stats'] }); // Partial reload
|
|
|
343
346
|
- Access shared props via `usePage().props`
|
|
344
347
|
- `processing` boolean from `useForm` for button loading states
|
|
345
348
|
|
|
349
|
+
## Vite Build Gotchas (Inertia-specific)
|
|
350
|
+
|
|
351
|
+
> **Production post-mortem 2026-05.** Reverting/rebuilding `vite.config.js`
|
|
352
|
+
> chunking in an Inertia + Laravel project can produce a **silent wrong-component
|
|
353
|
+
> render** (Login renders HttpErrorPage with HTTP 200, no console errors, empty
|
|
354
|
+
> Laravel logs). The bug lives in the bundle graph, not the server.
|
|
355
|
+
|
|
356
|
+
### The three-way collision
|
|
357
|
+
|
|
358
|
+
```
|
|
359
|
+
laravel-vite-plugin ┐
|
|
360
|
+
laravel({ input: [ │ Anything here becomes an ENTRY chunk.
|
|
361
|
+
'.../HttpErrorPage' │ Rollup/Rolldown ALWAYS prioritizes entries.
|
|
362
|
+
]}) ┘
|
|
363
|
+
|
|
364
|
+
@inertiajs/react ┐
|
|
365
|
+
resolvePageComponent( │ Builds a STATIC resolve-map at build time
|
|
366
|
+
`./Pages/${name}.jsx`,│ via import.meta.glob. The map points each
|
|
367
|
+
import.meta.glob( │ Page path to whatever chunk hash Rollup
|
|
368
|
+
'./Pages/**/*.jsx') │ ended up putting that module in.
|
|
369
|
+
) ┘
|
|
370
|
+
|
|
371
|
+
build.rollupOptions ┐
|
|
372
|
+
.manualChunks(id) { │ This is ADVISORY. Returning 'pages-auth' for
|
|
373
|
+
if (/Auth\//.test(id))│ a module that is ALSO an entry has NO EFFECT.
|
|
374
|
+
return 'pages-auth' │ The grouping is silently dropped — no warning.
|
|
375
|
+
} ┘
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
When the same module (e.g. `HttpErrorPage.jsx`) is:
|
|
379
|
+
|
|
380
|
+
1. listed in `laravel.input[]` (because `app.blade.php` has a direct
|
|
381
|
+
`@vite('resources/js/Pages/.../HttpErrorPage.jsx')` for the error layout), AND
|
|
382
|
+
2. caught by `manualChunks` returning `'pages-auth'`,
|
|
383
|
+
|
|
384
|
+
…Rollup/Rolldown creates a **standalone entry chunk** containing ONLY
|
|
385
|
+
`HttpErrorPage`. The `pages-auth` group is reduced to that one module, and the
|
|
386
|
+
`import.meta.glob` resolve-map points **every sibling** (`Login`, `Register`,
|
|
387
|
+
`ForgotPassword`, …) at that same chunk hash. The siblings' code is discarded.
|
|
388
|
+
|
|
389
|
+
Symptom: `Inertia::render('Auth/Login', …)` returns 200 OK with a valid Inertia
|
|
390
|
+
payload. The browser fetches the chunk listed in the resolve-map. That chunk's
|
|
391
|
+
`default` export is `HttpErrorPage`. The error page is rendered. Backend is
|
|
392
|
+
perfect; frontend lies.
|
|
393
|
+
|
|
394
|
+
### Rule 1 — Never duplicate a module between `laravel.input` and the glob
|
|
395
|
+
|
|
396
|
+
If a page MUST be referenced directly by `@vite()` in a blade file (typical
|
|
397
|
+
cases: error pages, mail templates rendered server-side, OG/share-image
|
|
398
|
+
renderers), it becomes an entry chunk. You have two options:
|
|
399
|
+
|
|
400
|
+
- **A. Exclude it from the glob** — change `resolvePageComponent`'s glob to
|
|
401
|
+
ignore that path, OR
|
|
402
|
+
- **B. Short-circuit `manualChunks` BEFORE any grouping rule** so the entry
|
|
403
|
+
chunk's identity is preserved without polluting a group.
|
|
404
|
+
|
|
405
|
+
Pattern B (preferred — works without touching the Inertia bootstrap):
|
|
406
|
+
|
|
407
|
+
```js
|
|
408
|
+
// vite.config.js
|
|
409
|
+
build: {
|
|
410
|
+
rollupOptions: {
|
|
411
|
+
output: {
|
|
412
|
+
manualChunks(id) {
|
|
413
|
+
if (!id.includes('/resources/js/Pages/')) return;
|
|
414
|
+
|
|
415
|
+
// Entry chunks referenced directly by @vite() in blade MUST
|
|
416
|
+
// return undefined here — BEFORE any grouping rule.
|
|
417
|
+
// Otherwise the group is collapsed into the entry and the
|
|
418
|
+
// import.meta.glob resolve-map points siblings at the wrong
|
|
419
|
+
// chunk hash. See "Vite Build Gotchas" in inertia-react skill.
|
|
420
|
+
if (id.includes('/Pages/OtherPages/HttpErrorPage')) return;
|
|
421
|
+
|
|
422
|
+
if (id.includes('/Pages/Auth/') || id.includes('/Pages/OtherPages/')) {
|
|
423
|
+
return 'pages-auth';
|
|
424
|
+
}
|
|
425
|
+
if (id.includes('/Pages/Dashboard/')) {
|
|
426
|
+
return 'pages-dashboard';
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Rule 2 — `manualChunks` is advisory; entries always win
|
|
435
|
+
|
|
436
|
+
There is no warning when Rollup/Rolldown ignores a `manualChunks` return value
|
|
437
|
+
for an entry module. The validation MUST be done out-of-band on `manifest.json`
|
|
438
|
+
after every build (see `scripts/check-vite-manifest.mjs` shipped by
|
|
439
|
+
`start-vibing-stacks`).
|
|
440
|
+
|
|
441
|
+
### Rule 3 — Validate `public/build/manifest.json` after every `vite build`
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
# 1. Quick eyeball
|
|
445
|
+
cat public/build/manifest.json | jq 'keys'
|
|
446
|
+
|
|
447
|
+
# 2. Automated (shipped script)
|
|
448
|
+
node scripts/check-vite-manifest.mjs public/build/manifest.json vite.config.js
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
The script cross-references `laravel.input[]` against the `Pages/**` glob and
|
|
452
|
+
fails (non-zero exit) on any collision.
|
|
453
|
+
|
|
454
|
+
### Rule 4 — Smoke-test after any `vite.config.js` change touching `build`
|
|
455
|
+
|
|
456
|
+
Three-step browser check for one critical route per page group:
|
|
457
|
+
|
|
458
|
+
1. DevTools → Network → click the Inertia XHR → read the `component:` field
|
|
459
|
+
in the JSON response.
|
|
460
|
+
2. Click the JS asset request that followed → open the resolved chunk URL.
|
|
461
|
+
3. Search for `default:` / `export{ ... as default}` in that chunk. The
|
|
462
|
+
component name MUST match the server's `component:`.
|
|
463
|
+
|
|
464
|
+
If they don't match, the bug is in the chunk graph. Revert the last
|
|
465
|
+
`vite.config.js` change before debugging anything else.
|
|
466
|
+
|
|
467
|
+
### Why Sonnet (and humans) get stuck on this class of bug
|
|
468
|
+
|
|
469
|
+
| Layer | What it looks like | Why it misleads |
|
|
470
|
+
|---|---|---|
|
|
471
|
+
| Laravel logs | Empty | Backend is genuinely correct |
|
|
472
|
+
| `manifest.json` | Valid, every input has an entry | Manifest doesn't enforce uniqueness across groups |
|
|
473
|
+
| Browser network tab | 200 OK on every request | The wrong JS arrives successfully |
|
|
474
|
+
| Console | Clean | The rendered component is valid React |
|
|
475
|
+
|
|
476
|
+
Triage rule of thumb: **when wrong-component render happens with HTTP 200 and
|
|
477
|
+
empty server logs, the bug is in the bundle. Start from the chunk graph, not
|
|
478
|
+
from Laravel.** This is captured as the "Bundle, not Backend" pattern in
|
|
479
|
+
`debugging-patterns`.
|
|
480
|
+
|
|
346
481
|
## Forbidden Patterns
|
|
347
482
|
|
|
348
483
|
| Pattern | Reason | Use Instead |
|
|
@@ -354,3 +489,6 @@ router.reload({ only: ['stats'] }); // Partial reload
|
|
|
354
489
|
| `window.location` for navigation | Full page reload | `router.visit()` |
|
|
355
490
|
| `Inertia::render()` after POST | Breaks Inertia protocol | `redirect()->route()` |
|
|
356
491
|
| Loading all translations globally | Performance waste | On-demand per page route |
|
|
492
|
+
| Same module in `laravel.input[]` AND `Pages/**` glob | Rollup collapses the manual chunk group into the entry; resolve-map points siblings at the wrong chunk (silent wrong-component render) | Short-circuit `manualChunks` with early `return undefined` for entry pages |
|
|
493
|
+
| `manualChunks` grouping pages without running `check-vite-manifest.mjs` | Grouping is advisory and silently ignored for entry chunks | Always run the validator after touching `build.rollupOptions` |
|
|
494
|
+
| Trusting empty Laravel logs as "no bug" | Server can be 100% correct while the bundle ships the wrong default export | Use the "Bundle, not Backend" triage in `debugging-patterns` |
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
@@ -323,6 +323,147 @@ See `error-handling` Pattern 5 for circuit breaker tuning and Pattern 4 for retr
|
|
|
323
323
|
- [ ] Tested: duplicate delivery → 200 (no duplicate side-effect)
|
|
324
324
|
- [ ] Tested: invalid signature → 401, never reaches the parser
|
|
325
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
|
+
|
|
326
467
|
## Metadata
|
|
327
468
|
|
|
328
469
|
```tsx
|
|
@@ -448,3 +589,5 @@ export async function createCheckout(priceId: string) {
|
|
|
448
589
|
10. **Webhook business logic inline in the Route Handler** — ack 2xx fast, process async (see "Webhook Handler — Critical Path")
|
|
449
590
|
11. **Skipping signature verification or parsing JSON before verifying** — always verify the raw body first
|
|
450
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
|
@@ -21,7 +21,8 @@
|
|
|
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
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": "
|
|
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 }
|
|
25
26
|
],
|
|
26
27
|
"frameworks": [
|
|
27
28
|
{
|
|
@@ -46,6 +46,17 @@ jobs:
|
|
|
46
46
|
if [ "${code:-0}" = "1" ]; then exit 1; fi
|
|
47
47
|
fi
|
|
48
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
|
+
|
|
49
60
|
- name: Build
|
|
50
61
|
run: bun run build
|
|
51
62
|
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-vite-manifest.mjs
|
|
4
|
+
*
|
|
5
|
+
* Static validator for Vite + laravel-vite-plugin builds that catches the
|
|
6
|
+
* "entry chunk collides with manualChunks group" bug in Inertia/React apps.
|
|
7
|
+
*
|
|
8
|
+
* Production post-mortem 2026-05: a `vite.config.js` "perf" change added
|
|
9
|
+
* manualChunks(id) { if (/Auth\//.test(id)) return 'pages-auth' }
|
|
10
|
+
* while `laravel.input[]` already listed `Pages/.../HttpErrorPage.jsx`
|
|
11
|
+
* (referenced directly via `@vite()` in `app.blade.php` for the error layout).
|
|
12
|
+
* Rollup/Rolldown silently dropped the manualChunks group, kept only the
|
|
13
|
+
* entry, and the `import.meta.glob` resolve-map in `resolvePageComponent`
|
|
14
|
+
* ended up pointing every sibling page (Login, Register, ForgotPassword) at
|
|
15
|
+
* the same single-module entry chunk. Server sent `component: 'Auth/Login'`,
|
|
16
|
+
* browser rendered HttpErrorPage. HTTP 200. Empty Laravel logs.
|
|
17
|
+
*
|
|
18
|
+
* Rules enforced (all silent failures in Rollup/Rolldown by default):
|
|
19
|
+
*
|
|
20
|
+
* R1. Every path in `laravel.input[]` MUST appear in `manifest.json` with
|
|
21
|
+
* `isEntry: true`.
|
|
22
|
+
* R2. Any `laravel.input[]` path that ALSO matches a `import.meta.glob`
|
|
23
|
+
* pattern in the project's JS is a COLLISION. Either exclude it from
|
|
24
|
+
* the glob, OR short-circuit `manualChunks` with an early
|
|
25
|
+
* `return undefined` before grouping rules.
|
|
26
|
+
* R3. Any file under `resources/js/Pages/` that appears as `isEntry: true`
|
|
27
|
+
* in the manifest is suspect — entry pages must be inspected (they
|
|
28
|
+
* collapse manualChunks groups). If the file is referenced direct via
|
|
29
|
+
* `@vite()` in blade, document it; otherwise treat as a build leak.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* node scripts/check-vite-manifest.mjs # auto-detect
|
|
33
|
+
* node scripts/check-vite-manifest.mjs <manifest> <vite.config>
|
|
34
|
+
*
|
|
35
|
+
* Auto-detect order (when no args):
|
|
36
|
+
* manifest: public/build/manifest.json,
|
|
37
|
+
* public/build/.vite/manifest.json
|
|
38
|
+
* vite.config: vite.config.js, vite.config.mjs, vite.config.ts
|
|
39
|
+
*
|
|
40
|
+
* Exit codes:
|
|
41
|
+
* 0 OK (or no Vite/manifest found — skipped)
|
|
42
|
+
* 1 collision (entry × glob) detected
|
|
43
|
+
* 2 manifest missing an `isEntry: true` for a declared input
|
|
44
|
+
* 3 malformed input (could not parse vite.config or manifest)
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
48
|
+
import { existsSync } from 'node:fs';
|
|
49
|
+
import { resolve, join, relative, dirname, basename } from 'node:path';
|
|
50
|
+
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
const args = process.argv.slice(2);
|
|
53
|
+
|
|
54
|
+
// ── Auto-detect paths ────────────────────────────────────────────────────
|
|
55
|
+
const MANIFEST_CANDIDATES = [
|
|
56
|
+
'public/build/manifest.json',
|
|
57
|
+
'public/build/.vite/manifest.json',
|
|
58
|
+
];
|
|
59
|
+
const CONFIG_CANDIDATES = [
|
|
60
|
+
'vite.config.js',
|
|
61
|
+
'vite.config.mjs',
|
|
62
|
+
'vite.config.ts',
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const manifestPath = args[0]
|
|
66
|
+
? resolve(cwd, args[0])
|
|
67
|
+
: MANIFEST_CANDIDATES.map((p) => resolve(cwd, p)).find(existsSync);
|
|
68
|
+
|
|
69
|
+
const configPath = args[1]
|
|
70
|
+
? resolve(cwd, args[1])
|
|
71
|
+
: CONFIG_CANDIDATES.map((p) => resolve(cwd, p)).find(existsSync);
|
|
72
|
+
|
|
73
|
+
if (!manifestPath) {
|
|
74
|
+
console.error(
|
|
75
|
+
'[check-vite-manifest] No manifest.json found. ' +
|
|
76
|
+
'Run `npm run build` first, then re-run this check.',
|
|
77
|
+
);
|
|
78
|
+
process.exit(0); // Skip silently if no build artifact yet.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!configPath) {
|
|
82
|
+
console.error(
|
|
83
|
+
'[check-vite-manifest] No vite.config.{js,mjs,ts} found. Skipping.',
|
|
84
|
+
);
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Load manifest ────────────────────────────────────────────────────────
|
|
89
|
+
let manifest;
|
|
90
|
+
try {
|
|
91
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error(
|
|
94
|
+
`[check-vite-manifest] Could not parse ${relative(cwd, manifestPath)}: ${e.message}`,
|
|
95
|
+
);
|
|
96
|
+
process.exit(3);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Load vite.config ─────────────────────────────────────────────────────
|
|
100
|
+
let configSrc;
|
|
101
|
+
try {
|
|
102
|
+
configSrc = await readFile(configPath, 'utf8');
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.error(`[check-vite-manifest] Could not read ${configPath}: ${e.message}`);
|
|
105
|
+
process.exit(3);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Extract laravel({ input: [...] }) ────────────────────────────────────
|
|
109
|
+
// Tolerant regex: matches `laravel({ ... input: [ '...', "..." ] ... })`
|
|
110
|
+
// across multiple lines. Also catches the helper form
|
|
111
|
+
// `laravel(['resources/css/app.css', 'resources/js/app.jsx'])`.
|
|
112
|
+
function extractLaravelInputs(src) {
|
|
113
|
+
const inputs = new Set();
|
|
114
|
+
|
|
115
|
+
// Form A: laravel({ input: [...] })
|
|
116
|
+
const formA = /laravel\s*\(\s*\{[\s\S]*?input\s*:\s*\[([\s\S]*?)\][\s\S]*?\}\s*\)/g;
|
|
117
|
+
for (const m of src.matchAll(formA)) {
|
|
118
|
+
const arr = m[1];
|
|
119
|
+
for (const sm of arr.matchAll(/['"`]([^'"`]+)['"`]/g)) inputs.add(sm[1]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Form B: laravel([ ... ]) (shorthand)
|
|
123
|
+
const formB = /laravel\s*\(\s*\[([\s\S]*?)\]\s*\)/g;
|
|
124
|
+
for (const m of src.matchAll(formB)) {
|
|
125
|
+
const arr = m[1];
|
|
126
|
+
for (const sm of arr.matchAll(/['"`]([^'"`]+)['"`]/g)) inputs.add(sm[1]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [...inputs];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Extract import.meta.glob patterns from JS sources ────────────────────
|
|
133
|
+
// We scan the bootstrap entries listed in laravel.input plus any obvious
|
|
134
|
+
// Inertia files (app.jsx, app.tsx, ssr.jsx, ssr.tsx, bootstrap.js) under
|
|
135
|
+
// resources/js/.
|
|
136
|
+
async function findInertiaGlobs(laravelInputs) {
|
|
137
|
+
const candidates = new Set();
|
|
138
|
+
for (const input of laravelInputs) {
|
|
139
|
+
if (/\.(jsx?|tsx?)$/.test(input)) candidates.add(input);
|
|
140
|
+
}
|
|
141
|
+
for (const f of ['app.jsx', 'app.tsx', 'ssr.jsx', 'ssr.tsx', 'bootstrap.js']) {
|
|
142
|
+
const p = `resources/js/${f}`;
|
|
143
|
+
if (existsSync(resolve(cwd, p))) candidates.add(p);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const patterns = [];
|
|
147
|
+
for (const rel of candidates) {
|
|
148
|
+
const abs = resolve(cwd, rel);
|
|
149
|
+
if (!existsSync(abs)) continue;
|
|
150
|
+
const src = await readFile(abs, 'utf8');
|
|
151
|
+
|
|
152
|
+
// resolvePageComponent(`./Pages/${name}.jsx`, import.meta.glob('./Pages/**/*.jsx'))
|
|
153
|
+
// We just want every `import.meta.glob('...')` literal.
|
|
154
|
+
for (const m of src.matchAll(
|
|
155
|
+
/import\.meta\.glob\s*\(\s*\[?\s*['"`]([^'"`]+)['"`]/g,
|
|
156
|
+
)) {
|
|
157
|
+
patterns.push({ file: rel, pattern: m[1] });
|
|
158
|
+
}
|
|
159
|
+
// Multi-pattern form: import.meta.glob(['./Pages/**/*.jsx', './Other/**/*.jsx'])
|
|
160
|
+
const multi = /import\.meta\.glob\s*\(\s*\[([\s\S]*?)\]/g;
|
|
161
|
+
for (const m of src.matchAll(multi)) {
|
|
162
|
+
for (const sm of m[1].matchAll(/['"`]([^'"`]+)['"`]/g)) {
|
|
163
|
+
patterns.push({ file: rel, pattern: sm[1] });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return patterns;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Convert a `import.meta.glob` pattern (relative to its host file) into an
|
|
171
|
+
// absolute prefix + suffix matcher. Globs supported: `**`, `*`, and explicit
|
|
172
|
+
// `{jsx,tsx,vue,svelte}` extension brace groups.
|
|
173
|
+
function compileGlob(hostFile, pattern) {
|
|
174
|
+
const hostDir = dirname(resolve(cwd, hostFile));
|
|
175
|
+
// Resolve `./` and `../` segments against the host directory.
|
|
176
|
+
let absPattern = resolve(hostDir, pattern);
|
|
177
|
+
absPattern = absPattern.replace(/\\/g, '/'); // Windows safety
|
|
178
|
+
|
|
179
|
+
// Build a regex from the glob.
|
|
180
|
+
// Step 1: expand brace groups `{a,b,c}`.
|
|
181
|
+
const expandBraces = (str) => {
|
|
182
|
+
const m = str.match(/\{([^{}]+)\}/);
|
|
183
|
+
if (!m) return [str];
|
|
184
|
+
const opts = m[1].split(',');
|
|
185
|
+
const out = [];
|
|
186
|
+
for (const o of opts) {
|
|
187
|
+
out.push(...expandBraces(str.slice(0, m.index) + o + str.slice(m.index + m[0].length)));
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// Step 2: convert each expansion into a regex.
|
|
193
|
+
const regexes = expandBraces(absPattern).map((expanded) => {
|
|
194
|
+
const reSrc = expanded
|
|
195
|
+
.replace(/[.+^$()|[\]\\]/g, '\\$&')
|
|
196
|
+
.replace(/\*\*/g, '__GLOBSTAR__')
|
|
197
|
+
.replace(/\*/g, '[^/]*')
|
|
198
|
+
.replace(/__GLOBSTAR__/g, '.*');
|
|
199
|
+
return new RegExp(`^${reSrc}$`);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return { pattern, hostFile, regexes };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function globMatches(compiled, absPath) {
|
|
206
|
+
const normalized = absPath.replace(/\\/g, '/');
|
|
207
|
+
return compiled.regexes.some((re) => re.test(normalized));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── Run validation ───────────────────────────────────────────────────────
|
|
211
|
+
const laravelInputs = extractLaravelInputs(configSrc);
|
|
212
|
+
if (laravelInputs.length === 0) {
|
|
213
|
+
console.error(
|
|
214
|
+
'[check-vite-manifest] Could not find laravel({ input: [...] }) in ' +
|
|
215
|
+
relative(cwd, configPath) +
|
|
216
|
+
'. Skipping (is this a Laravel + Vite project?).',
|
|
217
|
+
);
|
|
218
|
+
process.exit(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const errors = [];
|
|
222
|
+
const warnings = [];
|
|
223
|
+
|
|
224
|
+
// R1. Every laravel.input MUST be in the manifest as isEntry.
|
|
225
|
+
for (const input of laravelInputs) {
|
|
226
|
+
const entry = manifest[input];
|
|
227
|
+
if (!entry) {
|
|
228
|
+
errors.push({
|
|
229
|
+
rule: 'R1',
|
|
230
|
+
msg: `Input "${input}" declared in laravel.input but absent from manifest. Did the build fail?`,
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (!entry.isEntry) {
|
|
235
|
+
errors.push({
|
|
236
|
+
rule: 'R1',
|
|
237
|
+
msg: `Input "${input}" present in manifest but NOT marked isEntry. Check laravel-vite-plugin version.`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// R2 + R3. Cross-reference laravel.input against glob patterns.
|
|
243
|
+
const globs = await findInertiaGlobs(laravelInputs);
|
|
244
|
+
if (globs.length > 0) {
|
|
245
|
+
const compiledGlobs = globs.map((g) => compileGlob(g.file, g.pattern));
|
|
246
|
+
|
|
247
|
+
for (const input of laravelInputs) {
|
|
248
|
+
if (!/\.(jsx?|tsx?)$/.test(input)) continue; // skip CSS, etc.
|
|
249
|
+
const absInput = resolve(cwd, input);
|
|
250
|
+
for (const cg of compiledGlobs) {
|
|
251
|
+
if (globMatches(cg, absInput)) {
|
|
252
|
+
errors.push({
|
|
253
|
+
rule: 'R2',
|
|
254
|
+
msg:
|
|
255
|
+
`Collision: "${input}" is BOTH in laravel.input[] AND matched by ` +
|
|
256
|
+
`import.meta.glob("${cg.pattern}") in ${cg.hostFile}. ` +
|
|
257
|
+
`Rollup/Rolldown will silently collapse the manualChunks group ` +
|
|
258
|
+
`containing this module, leaving the glob resolve-map pointing ` +
|
|
259
|
+
`siblings at the wrong chunk hash. Either remove from ` +
|
|
260
|
+
`laravel.input[], exclude from the glob, or add early ` +
|
|
261
|
+
`\`return undefined\` for this module in manualChunks.`,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// R3. Pages/ files marked as isEntry that are NOT in laravel.input.
|
|
269
|
+
// These mean someone added an entry chunk indirectly (e.g. dynamic import
|
|
270
|
+
// promoted to entry by chunking heuristics) — worth flagging.
|
|
271
|
+
const declaredInputSet = new Set(laravelInputs);
|
|
272
|
+
for (const [key, entry] of Object.entries(manifest)) {
|
|
273
|
+
if (!entry.isEntry) continue;
|
|
274
|
+
if (declaredInputSet.has(key)) continue;
|
|
275
|
+
if (key.includes('resources/js/Pages/')) {
|
|
276
|
+
warnings.push({
|
|
277
|
+
rule: 'R3',
|
|
278
|
+
msg:
|
|
279
|
+
`Suspicious entry chunk: "${key}" is under Pages/ and marked ` +
|
|
280
|
+
`isEntry=true but NOT declared in laravel.input[]. This usually ` +
|
|
281
|
+
`means the bundler promoted it to entry to break a circular import ` +
|
|
282
|
+
`or because manualChunks excluded it. Verify the resolve-map of ` +
|
|
283
|
+
`import.meta.glob still points other pages at THIS chunk's siblings, ` +
|
|
284
|
+
`not at this chunk.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Report ───────────────────────────────────────────────────────────────
|
|
290
|
+
const tag = '[check-vite-manifest]';
|
|
291
|
+
|
|
292
|
+
if (warnings.length > 0) {
|
|
293
|
+
for (const w of warnings) console.warn(`${tag} WARN ${w.rule}: ${w.msg}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (errors.length === 0) {
|
|
297
|
+
console.log(
|
|
298
|
+
`${tag} OK ${laravelInputs.length} input(s), ${globs.length} glob(s), ` +
|
|
299
|
+
`${Object.keys(manifest).length} manifest entries — no collisions.`,
|
|
300
|
+
);
|
|
301
|
+
process.exit(0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const e of errors) console.error(`${tag} FAIL ${e.rule}: ${e.msg}`);
|
|
305
|
+
console.error(
|
|
306
|
+
`\n${tag} Manifest validation failed: ${errors.length} error(s). ` +
|
|
307
|
+
`See "Vite Build Gotchas" in the inertia-react skill for the fix pattern.`,
|
|
308
|
+
);
|
|
309
|
+
process.exit(errors.some((e) => e.rule === 'R1') ? 2 : 1);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: inertia-react
|
|
3
|
-
version: 2.
|
|
3
|
+
version: 2.1.0
|
|
4
4
|
description: LEGACY skill — Inertia.js + React with Laravel-rendered pages. Use
|
|
5
5
|
ONLY in pre-existing projects already built on Inertia. For NEW projects use
|
|
6
6
|
`laravel-api-architecture` + `axios-laravel-api` + `react-api-standards`
|
|
7
|
-
(API-first React SPA, no controller-rendered pages).
|
|
7
|
+
(API-first React SPA, no controller-rendered pages). v2.1.0 adds the "Vite
|
|
8
|
+
Build Gotchas" section covering the manualChunks × entry-chunk ×
|
|
9
|
+
resolvePageComponent glob collision (silent wrong-component render
|
|
10
|
+
post-mortem 2026-05).
|
|
8
11
|
---
|
|
9
12
|
|
|
10
13
|
# Inertia.js + React — Laravel Frontend (LEGACY)
|
|
@@ -207,6 +210,124 @@ export interface PaginatedData<T> {
|
|
|
207
210
|
}
|
|
208
211
|
```
|
|
209
212
|
|
|
213
|
+
## Vite Build Gotchas (Inertia-specific)
|
|
214
|
+
|
|
215
|
+
> **Production post-mortem 2026-05.** A `vite.config.js` "perf" change adding
|
|
216
|
+
> `manualChunks` to group `Pages/Auth/*` produced a silent wrong-component
|
|
217
|
+
> render: `Inertia::render('Auth/Login', ...)` returned HTTP 200 OK but the
|
|
218
|
+
> browser rendered the error page. Laravel logs were empty. Bug lived
|
|
219
|
+
> entirely in the bundle graph.
|
|
220
|
+
|
|
221
|
+
### The three-way collision
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
laravel-vite-plugin ┐ laravel({ input: [...HttpErrorPage] })
|
|
225
|
+
│ → module becomes an ENTRY chunk
|
|
226
|
+
│ Rollup/Rolldown ALWAYS prioritizes entries.
|
|
227
|
+
|
|
228
|
+
@inertiajs/react ┐ resolvePageComponent(..., import.meta.glob(
|
|
229
|
+
│ './Pages/**/*.jsx'))
|
|
230
|
+
│ → builds a STATIC resolve-map at build time
|
|
231
|
+
│ pointing each Page path to its chunk hash.
|
|
232
|
+
|
|
233
|
+
build.rollupOptions ┐ manualChunks(id) { if (Auth) return 'pages-auth' }
|
|
234
|
+
│ → ADVISORY ONLY. Returning a group for a module
|
|
235
|
+
│ that's already an entry has NO EFFECT and
|
|
236
|
+
│ emits NO WARNING.
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
When the same module (e.g. `HttpErrorPage.jsx`) is BOTH:
|
|
240
|
+
|
|
241
|
+
1. listed in `laravel.input[]` (typical: error pages referenced directly via
|
|
242
|
+
`@vite()` in `resources/views/app.blade.php` or a layout), AND
|
|
243
|
+
2. caught by a `manualChunks` rule (e.g. `pages-auth`),
|
|
244
|
+
|
|
245
|
+
…Rollup/Rolldown creates a **standalone entry chunk** with only that one
|
|
246
|
+
module. The `pages-auth` group collapses into that entry. The
|
|
247
|
+
`import.meta.glob` resolve-map points **every sibling page**
|
|
248
|
+
(`Login`, `Register`, `ForgotPassword`, …) at the same chunk hash. The
|
|
249
|
+
siblings' source is discarded.
|
|
250
|
+
|
|
251
|
+
Symptom: server sends `component: 'Auth/Login'`, browser loads the chunk,
|
|
252
|
+
chunk's `default` export is `HttpErrorPage`, error page renders. Server 100%
|
|
253
|
+
correct; bundle ships the wrong default export.
|
|
254
|
+
|
|
255
|
+
### Rule 1 — Never duplicate a module between `laravel.input` and the `Pages/**` glob
|
|
256
|
+
|
|
257
|
+
Either exclude the entry page from the Inertia glob, OR short-circuit
|
|
258
|
+
`manualChunks` BEFORE any grouping rule to preserve the entry's identity.
|
|
259
|
+
Preferred pattern (no change to Inertia bootstrap):
|
|
260
|
+
|
|
261
|
+
```js
|
|
262
|
+
// vite.config.js
|
|
263
|
+
build: {
|
|
264
|
+
rollupOptions: {
|
|
265
|
+
output: {
|
|
266
|
+
manualChunks(id) {
|
|
267
|
+
if (!id.includes('/resources/js/Pages/')) return;
|
|
268
|
+
|
|
269
|
+
// Entry chunks referenced directly by @vite() in blade MUST
|
|
270
|
+
// return undefined here — BEFORE any grouping rule. Otherwise
|
|
271
|
+
// the manualChunks group is collapsed into the entry and the
|
|
272
|
+
// import.meta.glob resolve-map points siblings at the wrong
|
|
273
|
+
// chunk hash. Production post-mortem 2026-05.
|
|
274
|
+
if (id.includes('/Pages/OtherPages/HttpErrorPage')) return;
|
|
275
|
+
|
|
276
|
+
if (id.includes('/Pages/Auth/') || id.includes('/Pages/OtherPages/')) {
|
|
277
|
+
return 'pages-auth';
|
|
278
|
+
}
|
|
279
|
+
if (id.includes('/Pages/Dashboard/')) {
|
|
280
|
+
return 'pages-dashboard';
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Rule 2 — `manualChunks` is advisory; entries always win
|
|
289
|
+
|
|
290
|
+
There is no warning when Rollup/Rolldown ignores a `manualChunks` return value
|
|
291
|
+
for an entry module. Validation MUST be done out-of-band on `manifest.json`
|
|
292
|
+
after every build.
|
|
293
|
+
|
|
294
|
+
### Rule 3 — Validate `public/build/manifest.json` after every `vite build`
|
|
295
|
+
|
|
296
|
+
`start-vibing-stacks` ships `scripts/check-vite-manifest.mjs` (wired as the
|
|
297
|
+
`ViteManifest` quality gate at order 5):
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
node scripts/check-vite-manifest.mjs public/build/manifest.json vite.config.js
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The script cross-references `laravel.input[]` against `Pages/**` glob patterns
|
|
304
|
+
and fails (non-zero exit) on any collision.
|
|
305
|
+
|
|
306
|
+
### Rule 4 — Smoke-test after any `vite.config.js` change touching `build`
|
|
307
|
+
|
|
308
|
+
For one critical route per page group:
|
|
309
|
+
|
|
310
|
+
1. DevTools → Network → click the Inertia XHR → read `component:` in the JSON.
|
|
311
|
+
2. Click the JS asset request that follows → open the resolved chunk URL.
|
|
312
|
+
3. Search for `default:` / `export{ ... as default}` in that chunk. The
|
|
313
|
+
exported component name MUST match the server's `component:`.
|
|
314
|
+
|
|
315
|
+
If they don't match, revert the last `vite.config.js` change before debugging
|
|
316
|
+
anything else.
|
|
317
|
+
|
|
318
|
+
### Why this class of bug is so misleading
|
|
319
|
+
|
|
320
|
+
| Layer | What it looks like | Why it misleads |
|
|
321
|
+
|---|---|---|
|
|
322
|
+
| Laravel logs | Empty | Backend is genuinely correct |
|
|
323
|
+
| `manifest.json` | Valid, every input has an entry | Manifest doesn't enforce uniqueness across groups |
|
|
324
|
+
| Browser network | 200 OK on every request | The wrong JS arrives successfully |
|
|
325
|
+
| Console | Clean | The rendered component is valid React |
|
|
326
|
+
|
|
327
|
+
Triage rule: **wrong-component render with HTTP 200 and empty server logs =
|
|
328
|
+
bug is in the bundle, not the server.** See `debugging-patterns §Bundle, not
|
|
329
|
+
Backend`.
|
|
330
|
+
|
|
210
331
|
## FORBIDDEN
|
|
211
332
|
|
|
212
333
|
1. **API routes for Inertia pages** — use `Inertia::render()` in controllers
|
|
@@ -214,3 +335,6 @@ export interface PaginatedData<T> {
|
|
|
214
335
|
3. **Fetching data in useEffect** — pass as props from controller
|
|
215
336
|
4. **Duplicating validation** — validate in FormRequest, show errors from `useForm`
|
|
216
337
|
5. **`any` types for page props** — always type with `interface Props extends PageProps`
|
|
338
|
+
6. **Same module in `laravel.input[]` and `Pages/**` glob** — Rollup collapses the manual chunk group into the entry; resolve-map silently points siblings at the wrong chunk hash (wrong-component render with HTTP 200)
|
|
339
|
+
7. **`manualChunks` grouping pages without running `check-vite-manifest.mjs`** — grouping is advisory and silently ignored for entry chunks
|
|
340
|
+
8. **Trusting empty Laravel logs as "no bug"** — server can be 100% correct while the bundle ships the wrong `default` export
|
package/stacks/php/stack.json
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
{ "name": "PHPStan", "command": "vendor/bin/phpstan analyse --level=6", "required": true, "order": 1 },
|
|
20
20
|
{ "name": "PHPUnit", "command": "vendor/bin/phpunit", "required": true, "order": 2 },
|
|
21
21
|
{ "name": "TypeCheck", "command": "npx tsc --noEmit", "required": true, "order": 3 },
|
|
22
|
-
{ "name": "Lint", "command": "npx eslint resources/js/", "required": false, "order": 4 }
|
|
22
|
+
{ "name": "Lint", "command": "npx eslint resources/js/", "required": false, "order": 4 },
|
|
23
|
+
{ "name": "ViteManifest", "command": "node scripts/check-vite-manifest.mjs", "required": false, "order": 5 }
|
|
23
24
|
],
|
|
24
25
|
"frameworks": [
|
|
25
26
|
{
|
package/templates/CLAUDE-php.md
CHANGED
|
@@ -310,6 +310,22 @@ const stripePublishable = import.meta.env.VITE_STRIPE_KEY; // pk_...
|
|
|
310
310
|
| `useEffect` to derive state | Anti-pattern — use `useMemo` |
|
|
311
311
|
| Skipping skeleton/empty/error states | Bad UX — all three are mandatory per page |
|
|
312
312
|
|
|
313
|
+
### Vite Build (CRITICAL — silent bug class)
|
|
314
|
+
|
|
315
|
+
> **Production post-mortem 2026-05.** A bad `manualChunks` rule can produce
|
|
316
|
+
> silent wrong-component renders (HTTP 200, empty Laravel logs, browser
|
|
317
|
+
> shows the error page when server asked for `Auth/Login`). Bug lives in the
|
|
318
|
+
> bundle graph, not the server. See `inertia-react §Vite Build Gotchas` and
|
|
319
|
+
> `debugging-patterns §Bundle, not Backend`.
|
|
320
|
+
|
|
321
|
+
| Action | Reason |
|
|
322
|
+
|--------|--------|
|
|
323
|
+
| Same module in `laravel.input[]` and `Pages/**` glob | Rollup/Rolldown silently collapses the manualChunks group into the entry chunk; `import.meta.glob` resolve-map points siblings at the wrong hash |
|
|
324
|
+
| `manualChunks` grouping pages without short-circuit for entry pages | `manualChunks` is advisory — entries always win, with no warning. Add early `return undefined` for any page also listed in `laravel.input[]` |
|
|
325
|
+
| Skipping `node scripts/check-vite-manifest.mjs` after `vite build` | Bundler emits no warning on collision; manual validation is the only signal |
|
|
326
|
+
| Debugging Laravel/server when HTTP 200 + empty logs + wrong content | Triage as "Bundle, not Backend" — start from the JS chunk's `default` export, not the controller |
|
|
327
|
+
| Adding new `@vite('resources/js/Pages/...')` direct reference in blade without auditing `vite.config.js` | Promoting a page to entry chunk while it's still in the Inertia glob is the exact collision shape |
|
|
328
|
+
|
|
313
329
|
## UI/UX Design Intelligence
|
|
314
330
|
|
|
315
331
|
> When the project has a frontend, the **UI/UX Pro Max** skill is auto-installed. It provides 67 UI styles, 161 color palettes, 57 font pairings, and 161 industry-specific reasoning rules. It activates automatically for any UI/UX task.
|