create-pracht 0.6.0 → 0.6.2

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/skills/add-auth/SKILL.md +63 -143
  3. package/skills/add-capabilities/SKILL.md +409 -0
  4. package/skills/add-content/SKILL.md +242 -0
  5. package/skills/add-db/SKILL.md +93 -202
  6. package/skills/add-i18n/SKILL.md +178 -217
  7. package/skills/add-images/SKILL.md +203 -0
  8. package/skills/add-observability/SKILL.md +118 -15
  9. package/skills/add-openapi/SKILL.md +209 -0
  10. package/skills/audit-a11y/SKILL.md +8 -9
  11. package/skills/audit-agent-surface/SKILL.md +335 -0
  12. package/skills/audit-auth/SKILL.md +16 -11
  13. package/skills/audit-bundles/SKILL.md +56 -12
  14. package/skills/audit-csrf/SKILL.md +9 -10
  15. package/skills/audit-deps/SKILL.md +8 -8
  16. package/skills/audit-headers/SKILL.md +9 -10
  17. package/skills/audit-islands/SKILL.md +9 -10
  18. package/skills/audit-loaders/SKILL.md +23 -8
  19. package/skills/audit-redirects/SKILL.md +9 -10
  20. package/skills/audit-secrets/SKILL.md +6 -6
  21. package/skills/audit-seo/SKILL.md +8 -8
  22. package/skills/audit-shells/SKILL.md +8 -9
  23. package/skills/configure-isg/SKILL.md +9 -10
  24. package/skills/migrate-nextjs/SKILL.md +200 -415
  25. package/skills/pracht-debug/SKILL.md +165 -120
  26. package/skills/pracht-deploy/SKILL.md +248 -329
  27. package/skills/pracht-scaffold/SKILL.md +123 -146
  28. package/skills/pracht-test-api/SKILL.md +10 -10
  29. package/skills/pre-deploy/SKILL.md +166 -195
  30. package/skills/scaffold-e2e/SKILL.md +11 -12
  31. package/skills/scaffold-tests/SKILL.md +10 -12
  32. package/skills/tune-render-mode/SKILL.md +7 -8
  33. package/skills/typed-routes/SKILL.md +15 -11
  34. package/skills/upgrade-pracht/SKILL.md +12 -10
  35. package/src/index.js +43 -0
@@ -1,14 +1,12 @@
1
1
  ---
2
2
  name: pracht-deploy
3
- version: 1.2.0
3
+ version: 1.3.0
4
4
  description: |
5
- Pracht deployment guide. Walks through adapter configuration, building, and
6
- deploying to Node.js, Cloudflare Workers, Netlify, Vercel, or a pure static
7
- host. Handles platform config, Docker and production checklist.
8
- Use when asked to "deploy", "set up deployment", "configure adapter",
9
- "deploy to cloudflare", "deploy to netlify", "deploy to vercel", "static
10
- export", or
11
- "production build".
5
+ Configure a pracht adapter and deploy to Node, Cloudflare Workers, Netlify,
6
+ Vercel, or a pure static host: platform config, build, Docker, production
7
+ checklist.
8
+ Use for "deploy", "set up deployment", "configure adapter", "deploy to
9
+ cloudflare/netlify/vercel", "static export", "production build".
12
10
  allowed-tools:
13
11
  - Bash
14
12
  - Read
@@ -21,87 +19,64 @@ allowed-tools:
21
19
 
22
20
  # Pracht Deploy
23
21
 
24
- Guided adapter setup and deployment for pracht applications.
25
-
26
- ## Step 1: Determine the target
27
-
28
- Read `vite.config.ts` and `package.json` first — don't assume the current adapter.
29
- Ask the user where they want to deploy if not already clear from their message.
30
-
31
- If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_build`/`doctor`/`verify` MCP tools over shelling out. Note: `inspect_build` (like `pracht inspect build`) needs a prior `pracht build`, and `pracht inspect` requires the pracht plugin registered in the vite config.
32
-
33
- ## Supported Adapters
34
-
35
- | Adapter | Package | Status |
36
- | ------------------ | ---------------------------- | ------ |
37
- | Node.js | `@pracht/adapter-node` | Stable |
38
- | Cloudflare Workers | `@pracht/adapter-cloudflare` | Stable |
39
- | Netlify | `@pracht/adapter-netlify` | Stable |
40
- | Vercel | `@pracht/adapter-vercel` | Stable |
41
- | Static export | `@pracht/adapter-static` | Stable |
22
+ Read `vite.config.ts` and `package.json` before giving any advice — never
23
+ assume the current adapter — and ask the user for the target if their message
24
+ does not name one. Then read only that adapter's section below. Install the
25
+ adapter if it is missing (`pnpm add @pracht/adapter-*`), run `pracht build` to
26
+ confirm the build succeeds, smoke-test the production runtime, and never push
27
+ to production without explicit confirmation.
28
+
29
+ MCP: when the pracht MCP server is registered (docs/MCP.md), prefer
30
+ `inspect_build`/`doctor`/`verify` over shelling out. `inspect_build` (like
31
+ `pracht inspect build`) needs a prior `pracht build`; `pracht inspect` needs
32
+ the pracht plugin in the vite config.
33
+
34
+ | Target | Adapter package | Local smoke test |
35
+ | ------------------ | ---------------------------- | -------------------------------------- |
36
+ | Node.js | `@pracht/adapter-node` | `pracht preview` |
37
+ | Cloudflare Workers | `@pracht/adapter-cloudflare` | `pracht preview` (delegates to `wrangler dev`) |
38
+ | Netlify | `@pracht/adapter-netlify` | `pracht build && netlify dev` |
39
+ | Vercel | `@pracht/adapter-vercel` | `vercel build` / `vercel dev` |
40
+ | Static export | `@pracht/adapter-static` | `pracht preview` |
41
+
42
+ Every adapter is wired the same way — `pracht({ adapter: <name>Adapter(…) })`
43
+ in the `plugins` array of `vite.config.ts` — and every target builds with
44
+ `pracht build`, which emits `dist/client/` (assets + prerendered HTML),
45
+ `dist/server/` (server entry and build tooling),
46
+ `dist/server/isg-manifest.json` when ISG routes exist, and
47
+ `dist/client/.vite/manifest.json`.
42
48
 
43
49
  ---
44
50
 
45
- ## Node.js Deployment
46
-
47
- ### Setup
48
-
49
- 1. Ensure `@pracht/adapter-node` is installed.
50
- 2. In `vite.config.ts`:
51
- ```ts
52
- import { pracht } from "@pracht/vite-plugin";
53
- import { nodeAdapter } from "@pracht/adapter-node";
54
- export default {
55
- plugins: [
56
- pracht({
57
- adapter: nodeAdapter({ canonicalOrigin: "https://app.example.com" }),
58
- }),
59
- ],
60
- };
61
- ```
62
-
63
- Pin `canonicalOrigin` in production so `request.url` does not depend on the
64
- incoming `Host` header. `maxBodySize` is also available on `nodeAdapter()`.
65
- Only custom entries behind a trusted proxy that overwrites forwarded headers
66
- should use `createNodeRequestHandler({ trustProxy: true })`.
67
- If that proxy strips Vite's deploy base from the forwarded path, set
68
- `nodeAdapter({ basePathStripped: true })` (or the same option on a custom
69
- `createNodeRequestHandler`). Do not infer this from the first path segment: a
70
- route may legitimately begin with the same segment as the deploy base. The
71
- adapter restores the public base before `createContext()`, loaders, and API
72
- handlers receive the request.
73
- The proxy must also own the public bare-base redirect (`/app` to `/app/`) in
74
- this mode because the stripped origin cannot distinguish it from a legitimate
75
- base-free `/app` route.
76
-
77
- The Node adapter compresses responses by default (brotli/gzip negotiated via
78
- `Accept-Encoding`, streaming for dynamic bodies, an in-memory LRU for static
79
- assets). When the deployment sits behind a reverse proxy or CDN that already
80
- compresses responses, set `nodeAdapter({ compression: false })` so bodies are
81
- not compressed twice.
82
-
83
- ### Build
84
-
85
- ```bash
86
- pracht build
87
- ```
51
+ ## Node.js
88
52
 
89
- Produces:
90
-
91
- - `dist/client/` — static assets (JS, CSS, prerendered HTML)
92
- - `dist/server/server.js` — Node server entry
93
- - `dist/server/isg-manifest.json` — ISG revalidation config (if ISG routes exist)
94
- - `dist/client/.vite/manifest.json` — asset manifest for script/style injection
95
-
96
- ### Run
97
-
98
- ```bash
99
- node dist/server/server.js
53
+ ```ts
54
+ pracht({ adapter: nodeAdapter({ canonicalOrigin: "https://app.example.com" }) });
100
55
  ```
101
56
 
102
- Port 3000 by default. For a local production smoke test, `pracht preview` builds and runs the server in one step (`--port <n>`, `--skip-build` to reuse an existing build). For production: reverse proxy (nginx, Caddy), process manager (PM2, systemd), `NODE_ENV=production`.
103
-
104
- ### Docker
57
+ Run with `node dist/server/server.js` (port 3000 by default). For production,
58
+ put it behind a reverse proxy (nginx, Caddy) and a process manager (PM2,
59
+ systemd) with `NODE_ENV=production`. `pracht preview` builds and runs it in one
60
+ step (`--port <n>`, `--skip-build` to reuse a build).
61
+
62
+ - Pin `canonicalOrigin` in production so `request.url` does not depend on the
63
+ incoming `Host` header. `maxBodySize` is also available on `nodeAdapter()`.
64
+ - `createNodeRequestHandler({ trustProxy: true })` is only for custom entries
65
+ behind a trusted proxy that overwrites forwarded headers.
66
+ - If that proxy strips Vite's deploy base from the forwarded path, set
67
+ `nodeAdapter({ basePathStripped: true })` (same option on a custom
68
+ `createNodeRequestHandler`). Do not infer this from the first path segment —
69
+ a route may legitimately begin with the same segment as the deploy base. The
70
+ adapter restores the public base before `createContext()`, loaders, and API
71
+ handlers see the request, and the proxy must then own the public bare-base
72
+ redirect (`/app` → `/app/`), since the stripped origin cannot tell it from a
73
+ legitimate base-free `/app` route.
74
+ - Responses are compressed by default (brotli/gzip negotiated via
75
+ `Accept-Encoding`, streaming for dynamic bodies, an in-memory LRU for static
76
+ assets). Behind a proxy or CDN that already compresses, set
77
+ `nodeAdapter({ compression: false })` to avoid doing it twice.
78
+
79
+ Docker:
105
80
 
106
81
  ```dockerfile
107
82
  FROM node:22-alpine
@@ -114,53 +89,23 @@ CMD ["node", "dist/server/server.js"]
114
89
 
115
90
  ---
116
91
 
117
- ## Cloudflare Workers Deployment
118
-
119
- ### Setup
92
+ ## Cloudflare Workers
120
93
 
121
- 1. Ensure `@pracht/adapter-cloudflare` is installed.
122
- 2. In `vite.config.ts`:
123
- ```ts
124
- import { pracht } from "@pracht/vite-plugin";
125
- import { cloudflareAdapter } from "@pracht/adapter-cloudflare";
126
- export default { plugins: [pracht({ adapter: cloudflareAdapter() })] };
127
- ```
128
-
129
- ### Build & Deploy
130
-
131
- ```bash
132
- pracht build
133
- npx wrangler deploy
94
+ ```ts
95
+ pracht({ adapter: cloudflareAdapter() });
134
96
  ```
135
97
 
136
- To smoke-test the built worker locally first, run `pracht preview` — it builds and then delegates to `wrangler dev`, which serves the wrangler config's `main` entry, `dist/server/worker.js`.
137
-
138
- Wrangler owns the Worker's binding environment. Put local-only secrets such as
139
- `PRACHT_CONFIRMATION_SECRET` and `PRACHT_REVALIDATE_TOKEN` in a gitignored
140
- `.dev.vars`; prefixing the host command with those variables does not
141
- automatically expose them inside the Worker. Keep production values in
142
- `wrangler secret`.
143
-
144
- If the config contains a custom-domain route, preview can listen on localhost
145
- while `request.url` inside the Worker uses the custom domain. Sign that
146
- effective `@authority` for Web Bot Auth or temporarily disable the route. To use
147
- a separate local config, build first, then run:
148
-
149
98
  ```bash
150
- pracht build
151
- npx wrangler dev --config wrangler.local.jsonc --port 3000
99
+ pracht build && npx wrangler deploy
152
100
  ```
153
101
 
154
- The local config must keep `main: "dist/server/worker.js"` and omit the
155
- production route. `pracht preview` does not forward Wrangler's `--config` flag.
156
-
157
- ### Wrangler Configuration
158
-
159
102
  ```jsonc
160
- // wrangler.jsonc
103
+ // wrangler.jsonc — canonical version at examples/cloudflare/wrangler.jsonc
161
104
  {
162
105
  "name": "my-pracht-app",
163
106
  "main": "dist/server/worker.js",
107
+ "no_bundle": true,
108
+ "rules": [{ "type": "ESModule", "globs": ["**/*.js", "**/*.mjs"] }],
164
109
  "compatibility_date": "2026-04-06",
165
110
  "assets": {
166
111
  "binding": "ASSETS",
@@ -170,36 +115,37 @@ production route. `pracht preview` does not forward Wrangler's `--config` flag.
170
115
  }
171
116
  ```
172
117
 
173
- `"binding": "ASSETS"` and `"run_worker_first": true` are required. Without the binding, the worker's `env.ASSETS` resolves to nothing and the runtime silently falls back to `null` — headers and ISG manifests load empty, so SSG serving, ISG revalidation, and per-route headers all silently no-op. The canonical config lives at `examples/cloudflare/wrangler.jsonc`. If you rename the binding with `assetsBinding` (below), the wrangler `binding` value must match.
174
-
175
- ### Bindings (KV, D1, R2)
176
-
177
- ```ts
178
- export async function loader({ context }: LoaderArgs) {
179
- const value = await context.env.MY_KV.get("key");
180
- return { value };
181
- }
182
- ```
183
-
184
- Keep Cloudflare binding reads inside the loader, API handler, capability
185
- `run()`, or another request-time function. Although Workers permits top-level
186
- `env.MY_KV`, Pracht graph inspection intentionally fails such module-initializer
187
- reads because it cannot supply an authoritative binding without risking false
188
- graph metadata.
189
-
190
- ### Custom Assets Binding
191
-
192
- ```ts
193
- pracht({ adapter: cloudflareAdapter({ assetsBinding: "STATIC" }) });
194
- ```
195
-
196
- ### Named bindings and default-export handlers
197
-
198
- Durable Object and Workflow classes are named Worker exports. Re-export them
199
- from the module configured with `workerExportsFrom`. Queue consumers, Cron
200
- Triggers, and Email Routing are instead methods on the default export; expose
201
- named `queue`, `scheduled`, or `email` functions from the module configured
202
- with `workerHandlersFrom`:
118
+ `no_bundle: true`, the `ESModule` rule, `"binding": "ASSETS"`, and
119
+ `"run_worker_first": true` are all required. Without the first two, Wrangler
120
+ re-bundles Pracht's already-bundled Vite output — folding lazy server chunks
121
+ into the entry or dropping them from the upload. Without the binding,
122
+ `env.ASSETS` silently resolves to `null`: headers and ISG manifests load empty,
123
+ so SSG serving, ISG revalidation, and per-route headers all no-op. If you
124
+ rename the binding via `cloudflareAdapter({ assetsBinding: "STATIC" })`, the
125
+ wrangler `binding` value must match.
126
+
127
+ **Local preview.** `pracht preview` builds, then delegates to `wrangler dev`
128
+ against the config's `main`. Wrangler owns the binding environment: put
129
+ local-only secrets like `PRACHT_CONFIRMATION_SECRET` and
130
+ `PRACHT_REVALIDATE_TOKEN` in a gitignored `.dev.vars` (prefixing the host
131
+ command with them does not expose them inside the Worker) and keep production
132
+ values in `wrangler secret`. With a custom-domain route in the config, preview
133
+ listens on localhost while `request.url` inside the Worker uses the custom
134
+ domain — sign that effective `@authority` for Web Bot Auth, or temporarily
135
+ remove the route. `pracht preview` does not forward Wrangler's `--config`, so a
136
+ separate local config means `pracht build && npx wrangler dev --config
137
+ wrangler.local.jsonc --port 3000`, and that config must keep
138
+ `main: "dist/server/worker.js"`, `no_bundle: true`, and the `ESModule` rule
139
+ while omitting the production route.
140
+
141
+ **Bindings.** Read them inside a loader, API handler, capability `run()`, or
142
+ another request-time function — `context.env.MY_KV.get("key")`. Workers permits
143
+ top-level `env` reads, but graph inspection deliberately fails them rather than
144
+ report binding metadata it cannot authoritatively resolve. Durable Object and
145
+ Workflow classes are named Worker exports: re-export them from
146
+ `workerExportsFrom`. Queue consumers, Cron Triggers, and Email Routing are
147
+ methods on the default export: expose named `queue`, `scheduled`, or `email`
148
+ functions from `workerHandlersFrom`.
203
149
 
204
150
  ```ts
205
151
  cloudflareAdapter({
@@ -208,220 +154,193 @@ cloudflareAdapter({
208
154
  });
209
155
  ```
210
156
 
211
- ### ISG via Workers Caching
212
-
213
- ISG works out of the box: without any cache option, the default worker-managed path serves the build-time snapshot, detects staleness, and regenerates pages in the background via the Workers Cache API — per colo — and `POST /__pracht/revalidate` triggers on-demand regeneration. Enabling `cache: true` moves ISG from that per-colo worker-managed path to edge-tier Workers Caching, on both sides:
214
-
215
- ```ts
216
- pracht({ adapter: cloudflareAdapter({ cache: true }) });
217
- ```
218
-
219
- ```jsonc
220
- // wrangler.jsonc
221
- { "cache": { "enabled": true } }
222
- ```
223
-
224
- Before enabling it, audit ISG URLs for unbounded query strings. Workers Caching
157
+ **ISG.** Works with no cache option: the worker-managed path serves the
158
+ build-time snapshot, detects staleness, regenerates in the background per colo
159
+ via the Workers Cache API, and `POST /__pracht/revalidate` forces regeneration.
160
+ `cloudflareAdapter({ cache: true })` plus `{ "cache": { "enabled": true } }` in
161
+ wrangler.jsonc moves that to edge-tier Workers Caching: time-revalidated pages
162
+ then render on demand, are cached for their `revalidate` window (stale served
163
+ instantly while the Worker re-renders), and can be purged early with
164
+ `purgeCache()` from `@pracht/adapter-cloudflare/cache`. Webhook-only ISG routes
165
+ keep their build-time snapshots and the worker-managed path either way.
166
+
167
+ Audit ISG URLs for unbounded query strings before enabling it — Workers Caching
225
168
  keys the exact path and query string, including parameter order and trailing
226
- slashes; use a bounded query allowlist/canonical redirect or an uncached gateway
227
- with a pathname-only `cf.cacheKey`, and normalize `Accept` there for routes that
228
- export markdown or declare `markdown: true` for middleware-owned negotiation.
229
- See `docs/ADAPTERS.md#cache-key-cardinality`.
230
-
231
- Time-revalidated ISG pages then render on demand, are cached at the edge for
232
- their `revalidate` window (stale pages served instantly while the Worker
233
- re-renders in the background), and can be purged early with `purgeCache()` from
234
- `@pracht/adapter-cloudflare/cache`. Webhook-only ISG routes keep their
235
- build-time snapshots and the worker-managed path either way.
169
+ slashes. Use a bounded query allowlist or canonical redirect, or an uncached
170
+ gateway with a pathname-only `cf.cacheKey`, and normalize `Accept` there for
171
+ routes that export markdown or declare `markdown: true`. See
172
+ `docs/ADAPTERS.md#cache-key-cardinality`.
236
173
 
237
174
  ---
238
175
 
239
- ## Netlify Deployment
176
+ ## Netlify
240
177
 
241
- ### Setup
242
-
243
- 1. Ensure `@pracht/adapter-netlify` and `netlify-cli` are installed.
244
- 2. In `vite.config.ts`:
245
- ```ts
246
- import { pracht } from "@pracht/vite-plugin";
247
- import { netlifyAdapter } from "@pracht/adapter-netlify";
248
- export default { plugins: [pracht({ adapter: netlifyAdapter() })] };
249
- ```
250
- 3. Add `netlify.toml`:
251
- ```toml
252
- [build]
253
- command = "pnpm build"
254
- publish = "dist/client"
178
+ ```ts
179
+ pracht({ adapter: netlifyAdapter() });
180
+ ```
255
181
 
256
- [functions]
257
- directory = "netlify/functions"
258
- ```
182
+ ```toml
183
+ # netlify.toml
184
+ [build]
185
+ command = "pnpm build"
186
+ publish = "dist/client"
259
187
 
260
- ### Build, Preview, and Deploy
188
+ [functions]
189
+ directory = "netlify/functions"
190
+ ```
261
191
 
262
192
  ```bash
263
193
  npx pracht build && npx netlify dev
264
194
  npx netlify deploy --build --prod
265
195
  ```
266
196
 
267
- The build emits `netlify/functions/pracht.mjs`. Page requests go through that
268
- function so Markdown negotiation and route-state requests remain correct;
269
- hashed assets bypass it and stay outside the function bundle at the origin
270
- root. With a Vite deploy base, the function instead bundles and serves the
271
- base-free asset and `/_pracht` trees so `/app/...` requests remain inside the
272
- mount. Custom `excludedPath` entries still bypass their literal origin-root
273
- URLs, but matching files remain bundled for base-prefixed requests. The
274
- generated config enumerates only client files the function can serve and roots
275
- applicable exclusions at the function file so Netlify's tracer cannot re-add
276
- bypassed trees. Netlify durable caching
277
- implements time-based ISG and per-path cache tags implement authenticated
278
- webhook revalidation. A trailing-slash ISG document request permanently
279
- redirects to the canonical slashless URL before rendering, and webhook
280
- revalidation normalizes either spelling before purging the cache tag.
281
- Only `Cache-Control`, `CDN-Cache-Control`, and `Netlify-CDN-Cache-Control`
282
- override the adapter's cache defaults; provider-specific headers for another
283
- CDN do not. Set a cache window to `0` to disable stale serving or freshness.
284
- `Netlify-Vary` owns route-state variants, while the standard `Vary: Accept`
285
- header owns Markdown negotiation. Cacheable negotiated SSG representations use
286
- the same `Netlify-Vary` instructions as their prerendered HTML. Shared ISG
287
- renders strip visitor-specific request data and Netlify context metadata before
288
- loaders or context factories run.
289
-
290
- `pracht preview` exits with guidance because it cannot emulate Netlify's
291
- Functions and CDN behavior. Build the generated function before using
292
- `netlify dev` for the platform-shaped local runtime. Configure
293
- `PRACHT_REVALIDATE_TOKEN` in Netlify when webhook revalidation is enabled.
197
+ `pracht preview` exits with guidance here — it cannot emulate Netlify's
198
+ Functions and CDN. Build the generated function first, then use `netlify dev`.
199
+ Set `PRACHT_REVALIDATE_TOKEN` in Netlify when webhook revalidation is enabled.
200
+
201
+ The build emits `netlify/functions/pracht.mjs`. Page requests go through it so
202
+ Markdown negotiation and route-state requests stay correct; hashed assets
203
+ bypass it and stay outside the function bundle at the origin root. With a Vite
204
+ deploy base, the function instead bundles and serves the base-free asset and
205
+ `/_pracht` trees so `/app/...` requests stay inside the mount; custom
206
+ `excludedPath` entries still bypass their literal origin-root URLs, but their
207
+ files remain bundled for base-prefixed requests. The generated config
208
+ enumerates only client files the function can serve and roots exclusions at the
209
+ function file, so Netlify's tracer cannot re-add bypassed trees.
210
+ When MCP OAuth is enabled, an exclusion matching either the protected MCP
211
+ resource path or its reserved metadata paths is a build error because it would
212
+ bypass the framework's authentication handler.
213
+
214
+ Caching: Netlify durable caching implements time-based ISG and per-path cache
215
+ tags implement authenticated webhook revalidation. A trailing-slash ISG
216
+ document request permanently redirects to the canonical slashless URL before
217
+ rendering, and webhook revalidation normalizes either spelling before purging
218
+ the tag. Only `Cache-Control`, `CDN-Cache-Control`, and
219
+ `Netlify-CDN-Cache-Control` override the adapter's cache defaults —
220
+ provider-specific headers for another CDN do not; a window of `0` disables
221
+ stale serving or freshness. `Netlify-Vary` owns route-state variants while
222
+ standard `Vary: Accept` owns Markdown negotiation, and cacheable negotiated SSG
223
+ representations reuse their prerendered HTML's `Netlify-Vary` instructions.
224
+ Shared ISG renders strip visitor-specific request data and Netlify context
225
+ metadata before loaders or context factories run.
294
226
 
295
227
  ---
296
228
 
297
- ## Vercel Deployment
229
+ ## Vercel
298
230
 
299
- ### Setup
300
-
301
- 1. Ensure `@pracht/adapter-vercel` is installed.
302
- 2. In `vite.config.ts`:
303
- ```ts
304
- import { pracht } from "@pracht/vite-plugin";
305
- import { vercelAdapter } from "@pracht/adapter-vercel";
306
- export default { plugins: [pracht({ adapter: vercelAdapter() })] };
307
- ```
308
-
309
- ### Build & Deploy
231
+ ```ts
232
+ pracht({ adapter: vercelAdapter() });
233
+ ```
310
234
 
311
235
  ```bash
312
- pracht build
313
- npx vercel deploy --prebuilt
236
+ pracht build && npx vercel deploy --prebuilt
314
237
  ```
315
238
 
316
- Produces: `.vercel/output/config.json`, `.vercel/output/static/`, `.vercel/output/functions/render.func/server.js`
239
+ Emits `.vercel/output/config.json`, `.vercel/output/static/`, and
240
+ `.vercel/output/functions/render.func/server.js`.
317
241
 
318
- There is no faithful local Vercel production runtime, so `pracht preview`
319
- exits with guidance. Use `vercel build` or `vercel dev`. Set
242
+ There is no faithful local Vercel production runtime, so `pracht preview` exits
243
+ with guidance — use `vercel build` or `vercel dev`. Set
320
244
  `PRACHT_REVALIDATE_TOKEN` at build time when using webhook revalidation; its
321
245
  Vercel bypass token is embedded in `.prerender-config.json`. Rename the main
322
- Edge Function with `vercelAdapter({ functionName })` if its default `render`
323
- name would collide with an ISG route. Custom entries must export the
324
- `nodeListener` created by `createVercelNodeListener(handle)` for Node ISR
325
- functions.
246
+ Edge Function with `vercelAdapter({ functionName })` if the default `render`
247
+ would collide with an ISG route. Custom entries must export the `nodeListener`
248
+ created by `createVercelNodeListener(handle)` for Node ISR functions.
249
+ When remote MCP is configured, the generated route table sends its endpoint
250
+ and OAuth metadata paths to the runtime before method-agnostic SSG rewrites;
251
+ keep page routes off the MCP pathname even though the runtime wins that collision.
326
252
 
327
253
  ---
328
254
 
329
- ## Static Export Deployment
330
-
331
- For apps where every route is `render: "ssg"` (or loaderless, full-hydration
332
- `"spa"`), with no
333
- request middleware, API routes, or HTTP/MCP/WebMCP-exposed capabilities. SSG
334
- loaders run only at build time and must produce HTML plus valid JSON route
335
- state; dynamic SSG routes must export `getStaticPaths()`. Anything else fails the build with an error naming the
336
- offenders — that is the signal to pick a serverful adapter instead. Only
337
- manifest-registered capabilities participate; every registered capability
338
- module must load successfully so exposure validation can fail closed. The
339
- `notFound` page must use full hydration (the default), because the shared
340
- `404.html` needs the client router to adopt the visitor's actual URL. Sub-path
341
- deploys (GitHub Pages *project* sites, S3 key prefixes) set Vite `base` to that
342
- path; CDN and document-relative bases (`""` / `"./"`) are build errors,
343
- because they split assets from the deploy root or resolve them beneath nested
344
- page directories. Under a base,
345
- internal navigation must go through `<Link route>` / `href()` — a hand-written
346
- `<a href="/about">` still means the origin root.
347
- Pracht's preview and first-party serverful adapters redirect the bare base
348
- (`/app`) to its trailing-slash form (`/app/`) before serving the root document;
349
- custom adapters receive the same behavior through `handlePrachtRequest()`.
350
- Framework-owned browser URLs from the default image loader and OpenAPI
351
- companion artifacts pick up the same base automatically.
352
-
353
- ### Setup
354
-
355
- 1. Ensure `@pracht/adapter-static` is installed.
356
- 2. In `vite.config.ts`:
357
- ```ts
358
- import { pracht } from "@pracht/vite-plugin";
359
- import { staticAdapter } from "@pracht/adapter-static";
360
- export default { plugins: [pracht({ adapter: staticAdapter() })] };
361
- // With dynamic SPA routes, add { fallback: "200.html" } and configure the
362
- // host to rewrite unmatched URLs to it. If the route or shell exports
363
- // head(), also set generic fallbackHead metadata shared by every rewrite.
364
- ```
365
-
366
- ### Build & Deploy
255
+ ## Static export
256
+
257
+ ```ts
258
+ pracht({ adapter: staticAdapter() });
259
+ // Dynamic SPA routes: staticAdapter({ fallback: "200.html" }) plus a host
260
+ // rewrite for unmatched URLs. If the route or shell exports head(), also set
261
+ // generic fallbackHead metadata shared by every rewrite.
262
+ ```
367
263
 
368
264
  ```bash
369
265
  pracht build # dist/client/ is the whole deployment
370
266
  pracht preview # local static file server over dist/client/
371
267
  ```
372
268
 
373
- Upload `dist/client/` to any static host (GitHub Pages, S3, nginx, Netlify).
374
- `dist/server/` is build tooling only — never deploy it. The host must serve
375
- `<dir>/index.html` for clean URLs and should use `404.html` as its error
376
- document. A static `notFound` page must use full hydration so that shared
377
- document can adopt the visitor's real URL. Client navigation fetches collision-safe
378
- bounded opaque `.json` files under `_pracht/state/` for full-hydration SSG
379
- routes whose loader or route/shell `head()` metadata participates in navigation;
380
- equivalent raw-Unicode and percent-encoded URL segment spellings resolve to the
381
- same state file. Explicitly loaderless and headless routes fetch no Pracht
382
- state; loaderless routes with head metadata fetch static state for font-head
383
- fragments but still use browser-side requests to an external API for live
384
- data. Files under `public/_pracht/state/` may not occupy a generated
385
- route-state path; the build rejects the collision instead of overwriting the
386
- public file. Files copied from `public/` or emitted by Vite also may not occupy
387
- the generated `404.html` or configured fallback path, including a case- or
388
- Unicode-normalization-equivalent spelling; the build rejects the portable
389
- collision instead of overwriting existing output. Generic `fallbackHead` fonts
390
- remain registered while the fallback commits a loaderless dynamic SPA route.
391
- See docs/ADAPTERS.md § Static Adapter for host header
392
- configuration and limitations (markdown negotiation, base paths). Pages are
393
- written to the percent-decoded output path, matching how static hosts resolve
394
- requests; `pracht preview` decodes request segments the same way. The SPA fallback only client-renders matched SPA routes; dynamic
395
- SSG paths omitted by `getStaticPaths()` render the app's not-found page with
396
- the build-time loader data or handled error state carried over from `404.html`.
397
- The host rewrite that serves the fallback answers unknown URLs with status 200 (soft 404), and an app
398
- with no `notFound` page and no unshadowed client-routable SPA catch-all renders them blank — the build
399
- warns about that shape. A dynamic SPA route, its shell, or the not-found page
400
- with `head()` requires an explicit `fallbackHead`, because the shared static
401
- document cannot evaluate URL-specific server metadata. Prerendered pages must
402
- map to distinct portable filesystem paths; duplicate/case-folded or
403
- Unicode-normalization-equivalent outputs, Windows-invalid or overlong filename
404
- components, and file/directory conflicts such as `/` with `/index.html` fail
405
- before any page is written. Fallback names likewise reject Windows reserved
406
- device names and the portable 255-byte/code-unit component limit.
269
+ Upload `dist/client/` to any static host. `dist/server/` is build tooling —
270
+ never deploy it. The host must serve `<dir>/index.html` for clean URLs and
271
+ should use `404.html` as its error document. See `docs/ADAPTERS.md` § Static
272
+ Adapter for host header configuration and the markdown-negotiation and
273
+ base-path limitations.
274
+
275
+ **Eligibility.** Every route must be `render: "ssg"` (or a loaderless,
276
+ full-hydration `"spa"`), with no request middleware, API routes, or
277
+ HTTP/MCP/WebMCP-exposed capabilities. Anything else fails the build with an
278
+ error naming the offenders — that is the signal to pick a serverful adapter.
279
+ SSG loaders run only at build time and must produce HTML plus valid JSON route
280
+ state; dynamic SSG routes must export `getStaticPaths()`. Only
281
+ manifest-registered capabilities participate, and every registered capability
282
+ module must load successfully so exposure validation fails closed. The
283
+ `notFound` page must use full hydration (the default) because the shared
284
+ `404.html` needs the client router to adopt the visitor's actual URL.
285
+
286
+ **Deploy base.** Sub-path deploys (GitHub Pages *project* sites, S3 key
287
+ prefixes) set Vite `base` to that path. CDN and document-relative bases (`""`,
288
+ `"./"`) are build errors — they split assets from the deploy root or resolve
289
+ them beneath nested page directories. Under a base, internal navigation must go
290
+ through `<Link route>` / `href()`; a hand-written `<a href="/about">` still
291
+ means the origin root. Preview and the first-party serverful adapters redirect
292
+ the bare base (`/app` → `/app/`) before serving the root document, and custom
293
+ adapters get the same behavior via `handlePrachtRequest()`. Framework-owned
294
+ browser URLs from the default image loader and the OpenAPI companion artifacts
295
+ pick up the base automatically.
296
+
297
+ **Route state.** Client navigation fetches collision-safe bounded opaque
298
+ `.json` files under `_pracht/state/`, for full-hydration SSG routes whose
299
+ loader or route/shell `head()` metadata participates in navigation. Equivalent
300
+ raw-Unicode and percent-encoded URL segment spellings resolve to the same state
301
+ file. Explicitly loaderless and headless routes fetch no state; loaderless
302
+ routes with head metadata fetch static state for font-head fragments but still
303
+ hit an external API from the browser for live data.
304
+
305
+ **Build-time collision guards** (all fail the build rather than overwrite):
306
+
307
+ - A file under `public/_pracht/state/` occupying a generated route-state path.
308
+ - A `public/` or Vite-emitted file occupying the generated `404.html` or the
309
+ configured fallback path, including case- or Unicode-normalization-equivalent
310
+ spellings.
311
+ - Prerendered pages that do not map to distinct portable filesystem paths:
312
+ duplicate, case-folded, or Unicode-normalization-equivalent outputs;
313
+ Windows-invalid or overlong filename components; file/directory conflicts
314
+ such as `/` against `/index.html`. Fallback names additionally reject Windows
315
+ reserved device names and the portable 255-byte/code-unit component limit.
316
+
317
+ Pages are written to the percent-decoded output path, matching how static hosts
318
+ resolve requests; `pracht preview` decodes request segments the same way.
319
+
320
+ **SPA fallback.** It only client-renders matched SPA routes. Dynamic SSG paths
321
+ omitted by `getStaticPaths()` render the app's not-found page with the
322
+ build-time loader data or handled error state carried over from `404.html`. The
323
+ host rewrite answers unknown URLs with status 200 (a soft 404), and an app with
324
+ no `notFound` page and no unshadowed client-routable SPA catch-all renders them
325
+ blank — the build warns about that shape. A dynamic SPA route, its shell, or
326
+ the not-found page exporting `head()` requires an explicit `fallbackHead`,
327
+ because the shared static document cannot evaluate URL-specific server
328
+ metadata; generic `fallbackHead` fonts stay registered while the fallback
329
+ commits a loaderless dynamic SPA route.
407
330
 
408
331
  ---
409
332
 
410
- ## Deployment Checklist
411
-
412
- 1. **Build**: Run `pracht build` and verify `dist/` output.
413
- 2. **Environment variables**: Ensure secrets/config needed by loaders are available at runtime.
414
- 3. **Static assets**: Verify `dist/client/` contains prerendered HTML for SSG routes (and ISG routes — except time-revalidated ISG routes on Cloudflare with Workers Caching enabled, which render on demand; webhook-only ISG routes keep their build-time snapshots).
415
- 4. **ISG routes**: Confirm the ISG manifest (`dist/server/isg-manifest.json`; on Cloudflare also `dist/client/_pracht/isg.json`) exists if using incremental static generation.
416
- 5. **API routes**: Test API endpoints work in the production runtime. For Node.js, run `pracht preview` (or `node dist/server/server.js`).
417
- 6. **Middleware**: Verify auth/redirect middleware behaves correctly in production.
418
-
419
- ## Rules
420
-
421
- 1. Read `vite.config.ts` and `package.json` before giving advice.
422
- 2. Run `pracht build` to verify the build succeeds before deploying.
423
- 3. Smoke-test the production runtime before pushing to production. For Node.js and Cloudflare, run `pracht preview`; for Netlify, run `pracht build && netlify dev`.
424
- 4. If the user needs an adapter that isn't installed, help them add it (`pnpm add @pracht/adapter-*`).
425
- 5. Don't push to production without the user's explicit confirmation.
333
+ ## Pre-flight checklist
334
+
335
+ 1. `pracht build` succeeds and `dist/` looks right.
336
+ 2. Every secret and config value the loaders need is present in the target
337
+ runtime.
338
+ 3. `dist/client/` holds prerendered HTML for SSG routes — and for ISG routes,
339
+ except time-revalidated ones on Cloudflare with Workers Caching enabled,
340
+ which render on demand. Webhook-only ISG routes keep build-time snapshots.
341
+ 4. The ISG manifest exists if ISG is in use: `dist/server/isg-manifest.json`,
342
+ plus `dist/client/_pracht/isg.json` on Cloudflare.
343
+ 5. API endpoints answer correctly in the production runtime.
344
+ 6. Auth and redirect middleware behave correctly in production.
426
345
 
427
346
  $ARGUMENTS