create-pracht 0.4.2 → 0.6.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.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-auth
3
- version: 1.1.0
3
+ version: 1.2.3
4
4
  description: |
5
5
  Find pracht routes that look protected but aren't — missing auth middleware,
6
6
  middleware that augments context but never gates, client-side auth checks
@@ -92,10 +92,35 @@ target. From `pracht inspect api --json`:
92
92
  `hasDefaultHandler: true` as "every method exposed". On older CLIs where
93
93
  the field is missing, grep the handler file for `export default` instead.
94
94
  - For each mutation handler (named method export or default handler) and each
95
- HTTP-exposed capability, check whether
95
+ HTTP- or remote-MCP-exposed capability, check whether
96
96
  `defineApp({ api: { middleware } })` applies a Gate, OR the handler/capability
97
97
  reads and validates a session itself. App-level API middleware wraps generated
98
98
  capability endpoints before capability-specific middleware.
99
+ - For remote MCP, cookie-bearing transport requests are rejected before
100
+ capability dispatch and only `Authorization` is forwarded. Flag MCP-exposed
101
+ capabilities whose gate depends on a browser session cookie or a custom
102
+ credential header that the projection does not carry.
103
+ - Treat `context.agent` as framework-owned, read-only verified identity. Flag
104
+ middleware or capability code that attempts to mutate or replace it instead
105
+ of deriving application authorization state on a separate context field.
106
+ - When a custom adapter supplies a frozen or sealed context, flag authorization
107
+ helpers that read `agent` or middleware-added fields through `this`. The
108
+ framework binds private-field methods to the immutable source receiver, which
109
+ cannot observe fields added on its extensible overlay. Callable fields keep
110
+ their own API and arrays keep their brand. Application-defined
111
+ `Symbol.toStringTag` branding does not affect whether an ordinary context can
112
+ be overlaid, but immutable native built-ins such as `Map` and `Date` fail
113
+ closed because an overlay cannot preserve their internal slots. Use a fresh
114
+ mutable wrapper when a context needs native built-ins or when receiver-bound
115
+ helpers depend on request state.
116
+ - Inspect every HTTP-, WebMCP-, or MCP-exposed capability body for
117
+ `invokeCapability()`. Direct composition never re-applies app-level API
118
+ middleware. Remote MCP additionally re-applies the callee's `agentPolicy`
119
+ and refuses destructive callees, but private non-destructive capabilities
120
+ stay composable and rely on their named middleware for authorization. For
121
+ HTTP/WebMCP composition, flag sensitive callees whose required transport
122
+ authorization or approval is absent from the composing capability and the
123
+ callee's named middleware.
99
124
  - Common bug: dashboard route is protected by middleware, but
100
125
  `POST /api/items` is not — attacker bypasses the UI entirely.
101
126
 
@@ -142,5 +167,10 @@ Severity is the primary scale; the verdict is a secondary domain label:
142
167
  4. Public routes deliberately exposed (login, signup, marketing) should be
143
168
  listed but not flagged.
144
169
  5. Do not auto-add middleware. Auth wiring is policy.
170
+ 6. Treat allowed composed capability reachability as transitive. MCP blocks
171
+ destructive callees and re-applies `agentPolicy`; named middleware remains
172
+ the authorization seam for private non-destructive composition. Audit events
173
+ identify every nested attempt with `transport: "server"` and trusted request
174
+ provenance in `via`, but observability is not an authorization gate.
145
175
 
146
176
  $ARGUMENTS
@@ -49,8 +49,11 @@ The audit surface is therefore:
49
49
  - **(c)** HSTS and CSP, which genuinely need user action.
50
50
 
51
51
  Prerequisites: `pracht inspect` requires a vite config that registers the
52
- pracht plugin; `pracht inspect build` and `dist/client/_pracht/headers.json`
53
- require a prior `pracht build`.
52
+ pracht plugin; `pracht inspect build` and
53
+ `dist/server/headers-manifest.json` require a prior `pracht build`. Every
54
+ serverful target currently publishes a client copy at
55
+ `dist/client/_pracht/headers.json`; only Cloudflare reads it there. Pure static
56
+ exports deliberately do not publish that copy.
54
57
 
55
58
  ## Step 1: Inventory header sources
56
59
 
@@ -134,9 +137,15 @@ the `headers()` sources statically to catch them **before** a build failure,
134
137
  and report each as `error` with the prerender failure it would cause.
135
138
 
136
139
  The real target is the `warn` class the framework cannot catch: innocuously
137
- named headers carrying user-specific values, which get copied into
138
- `dist/client/_pracht/headers.json` (public client output) and replayed across
139
- users on static responses.
140
+ named headers carrying user-specific values in
141
+ `dist/server/headers-manifest.json`, which serverful adapters may replay across
142
+ users on static responses. Every serverful build currently also copies that
143
+ manifest into public client output; Cloudflare reads it through the assets
144
+ binding, while the other adapters retain the public copy for compatibility. A
145
+ pure static export omits the client copy and has no runtime to replay the server
146
+ manifest; instead, verify that the deployment's host-header configuration
147
+ mirrors only safe, non-user-specific entries. Missing host configuration means
148
+ route `headers()` values are not applied at all.
140
149
 
141
150
  Secret VALUES in headers are owned by `audit-secrets`; this skill owns policy
142
151
  headers. Cross-reference `audit-secrets` for value-level findings.
@@ -95,10 +95,13 @@ Check for accidental exposure outside loaders:
95
95
 
96
96
  - `head()` returns: rare, but a `meta` value containing a token leaks into HTML.
97
97
  - `headers()` returns: flag values that look like secrets. For SSG/ISG pages,
98
- document headers can be copied into `dist/client/_pracht/headers.json`, which
99
- is public client output and may be replayed on static responses. This skill
100
- owns secret VALUES in headers; header policy (CSP, HSTS, weakened defaults)
101
- is owned by `audit-headers` cross-reference it.
98
+ document headers enter `dist/server/headers-manifest.json`; every serverful
99
+ build currently also copies that manifest to public
100
+ `dist/client/_pracht/headers.json` (only Cloudflare reads it there). A pure
101
+ static export omits the client copy but may mirror the server manifest into
102
+ host configuration. This skill owns secret VALUES in headers; header policy
103
+ (CSP, HSTS, weakened defaults) is owned by `audit-headers` — cross-reference
104
+ it.
102
105
  - `<Form>` `action` URLs containing tokens in the query string.
103
106
  - `prefetchRouteState(url)` calls with sensitive query params.
104
107
  - Inline `<script>` content emitted from custom shells.
@@ -62,12 +62,18 @@ route("/pricing", () => import("./routes/pricing.tsx"), {
62
62
  - `revalidate` accepts one policy or an array (`RouteRevalidate`); the array
63
63
  above means "hourly, or sooner when a webhook names this path".
64
64
 
65
- **Pages router caveat:** `export const RENDER_MODE = "isg"` exists, but there
66
- is no `REVALIDATE` page constant — the pages scanner only extracts
67
- `RENDER_MODE` and `HYDRATION`, so pages-router ISG routes are frozen
68
- build-time snapshots. To attach a policy, eject to an explicit manifest with
69
- `generateRoutesFile` from `@pracht/vite-plugin/pages-router` (see
70
- docs/ROUTING.md "Ejecting to Explicit Manifest") and edit the generated route.
65
+ **Pages router:** time-based ISG is expressed with two static page exports:
66
+
67
+ ```ts
68
+ export const RENDER_MODE = "isg";
69
+ export const REVALIDATE = 3600;
70
+ ```
71
+
72
+ `REVALIDATE` must be a positive integer literal number of seconds. Build,
73
+ `doctor`, and `verify` reject a missing or misplaced policy. Pages mode does
74
+ not accept policies on `_app` or `404`, and fenced Markdown/MDX examples are
75
+ ignored. It does not support webhook or combined policies; eject with `generateRoutesFile`
76
+ from `@pracht/vite-plugin/pages-router` for those.
71
77
 
72
78
  For dynamic routes, `getStaticPaths()` enumerates the prerendered params.
73
79
  Paths it did not enumerate render per-request without a cached copy, and
@@ -75,23 +81,29 @@ webhooks naming them are `skipped` on Node/Cloudflare (nothing to refresh).
75
81
 
76
82
  ## Step 3: The revalidation webhook
77
83
 
78
- All adapters expose `POST /__pracht/revalidate` (`PRACHT_REVALIDATE_ENDPOINT`
79
- from `@pracht/core`):
84
+ All adapters expose `POST <base>/__pracht/revalidate`
85
+ (`PRACHT_REVALIDATE_ENDPOINT` from `@pracht/core`). For example, an app with
86
+ `base: "/app/"` uses:
80
87
 
81
88
  ```sh
82
- curl -X POST https://example.com/__pracht/revalidate \
89
+ curl -X POST https://example.com/app/__pracht/revalidate \
83
90
  -H "Authorization: Bearer $PRACHT_REVALIDATE_TOKEN" \
84
91
  -H "Content-Type: application/json" \
85
92
  -d '{"paths":["/pricing"]}'
86
93
  ```
87
94
 
95
+ At the default `base: "/"`, omit `/app`. Keep request-body paths base-free;
96
+ they identify manifest routes rather than public deployment URLs.
97
+
88
98
  - Auth: `PRACHT_REVALIDATE_TOKEN` env var; fails closed with `401` when unset
89
99
  or wrong. Providers that can't send bearer auth may use the
90
100
  `x-pracht-revalidate-token` header instead.
91
101
  - Body: `paths` array, max 64 entries (else `400`). Response reports
92
- `revalidated` / `skipped` / `failed` arrays; failed paths keep serving the
93
- previous copy. Regeneration is single-flighted per path and never replays
94
- the caller's cookies/auth headers.
102
+ `revalidated` / `skipped` / `failed` arrays plus a `details` array naming why
103
+ each path was skipped (`not_a_route`, `not_isg`, `not_prerendered`,
104
+ `no_webhook_policy`) or failed — check `details` first when a webhook appears
105
+ to do nothing. Failed paths keep serving the previous copy. Regeneration is
106
+ single-flighted per path and never replays the caller's cookies/auth headers.
95
107
 
96
108
  ## Step 4: Adapter mechanics
97
109
 
@@ -100,7 +112,7 @@ curl -X POST https://example.com/__pracht/revalidate \
100
112
  | Node | File mtime vs window; serves stale, refreshes in background | Regenerates the on-disk HTML synchronously |
101
113
  | Cloudflare (default) | Worker-managed Cache API timestamp, `env.ASSETS` fallback — **per colo** | Overwrites the Cache API entry in the receiving colo only |
102
114
  | Cloudflare (`cache: true`) | Edge-tier Workers Caching in front of the Worker for time-revalidated routes | Webhook-only routes keep the worker-managed path; time+webhook routes also get their edge entry purged |
103
- | Vercel | Build Output prerender functions: `.prerender-config.json` with `expiration` from the time policy and build HTML as fallback | `x-vercel-cache`-verified bypass; `PRACHT_REVALIDATE_TOKEN` becomes the `bypassToken` and **must be set at build time** (runtime-only setting → webhook paths report `failed` until you rebuild) |
115
+ | Vercel | Build Output prerender functions: `.prerender-config.json` with `expiration` from the time policy and build HTML as fallback, next to a Node Serverless Function per ISG route (Vercel rejects ISR on an Edge Function) | For webhook revalidation, `x-vercel-cache` verifies the bypass; `PRACHT_REVALIDATE_TOKEN` becomes the `bypassToken` and **must be set at build time** (runtime-only setting → webhook paths report `failed` until you rebuild). Time-only ISR does not require the token. |
104
116
 
105
117
  Cloudflare specifics (`docs/ADAPTERS.md#isg-via-workers-caching-cache`):
106
118
 
@@ -127,8 +139,9 @@ exact path **plus query string** (param order and trailing slash included), so
127
139
  revalidation — and attacker-chosen query values create unbounded cold entries.
128
140
  Before enabling `cache: true`, canonicalize or reject stray query params (the
129
141
  docs describe an uncached-gateway pattern), and note that routes exporting
130
- `markdown` carry `Vary: Accept`, which multiplies variants per `Accept`
131
- string. Vercel prerender functions are generated with `allowQuery: []`, so
142
+ `markdown` or declaring middleware-owned `markdown: true` carry `Vary: Accept`,
143
+ which multiplies variants per `Accept` string. Vercel prerender functions are
144
+ generated with `allowQuery: []`, so
132
145
  query strings do not fragment that cache. Middleware never runs for cached ISG
133
146
  hits on any adapter — keep per-visitor logic on SSR routes.
134
147
 
@@ -158,9 +171,15 @@ pracht typegen # if src/routes.ts changed
158
171
 
159
172
  1. Never overwrite `wrangler.jsonc`/`wrangler.toml` or `vercel.json` — diff
160
173
  and merge, confirming collisions with `AskUserQuestion`.
161
- 2. Never propose ISG for personalized responses: `Set-Cookie` or
162
- `Cache-Control: private`/`no-store` output fails regeneration, and
163
- `Vary: Cookie`/`Authorization`/`*` is kept out of shared caches by design.
174
+ 2. Never propose ISG for personalized responses. ISG HTML renders always run
175
+ on a sanitized request (`GET`, `Accept: text/html`, path only — no cookies,
176
+ credentials, query, or body); Cloudflare Workers Caching uses the same
177
+ isolation with `Accept: text/markdown` for its markdown cache variant. A
178
+ loader that reads the session therefore sees an anonymous visitor. On top of that, `Set-Cookie` or
179
+ `Cache-Control: private`/`no-store` output fails regeneration on Node and
180
+ Cloudflare (on Vercel the credential headers are stripped and the mismatch
181
+ is logged), and `Vary: Cookie`/`Authorization`/`*` is kept out of shared
182
+ caches by design.
164
183
  3. Always pair `render: "isg"` with an explicit `revalidate` policy — without
165
184
  one the route silently behaves like SSG.
166
185
  4. On Vercel, set `PRACHT_REVALIDATE_TOKEN` in the build environment, not
@@ -53,7 +53,7 @@ If the source Next.js project uses the **pages router** (`pages/` directory), pr
53
53
  2. Copy `pages/` to `src/pages/`
54
54
  3. Convert `_app.tsx` to pracht shell format (`Shell` export + `children` prop)
55
55
  4. Convert `getServerSideProps`/`getStaticProps` to `loader` exports
56
- 5. Add `export const RENDER_MODE = "ssg"` to static pages, `"ssr"` for dynamic (default is `"ssr"`)
56
+ 5. Add `export const RENDER_MODE = "ssg"` to static pages, `"ssr"` for dynamic (default is `"ssr"`). For time-revalidated pages, export `RENDER_MODE = "isg"` and a positive integer `REVALIDATE` in seconds. Webhook policies require ejection.
57
57
  6. Run dev server, iterate on errors
58
58
  7. Optionally run `generateRoutesFile` to eject to explicit manifest
59
59
 
@@ -78,13 +78,14 @@ For pages router projects, you can **skip manual manifest wiring entirely** (Pha
78
78
  | `"use client"` (few, in a mostly-server app) | `hydration: "islands"` + `src/islands/` | Only islands ship JS; see the islands note in Phase 4 |
79
79
  | `revalidatePath` / `res.revalidate()` | `webhookRevalidate()` + `POST /__pracht/revalidate` | On-demand ISG regeneration; combinable with `timeRevalidate(seconds)` |
80
80
  | `useRouter()` (next/navigation) | `useNavigate()` from pracht | Accepts paths or typed route targets after `pracht typegen` |
81
- | `useSearchParams()` | `useLocation()` from pracht | Returns `{ pathname, search }`; loaders also receive `url` with searchParams |
81
+ | `useSearchParams()` | `useSearchParams()` from pracht | Returns reactive read-only params; SSG receives the browser query after hydration, while loaders use `url.searchParams` |
82
82
  | `useParams()` | `useParams()` from pracht | Direct equivalent; also available as `params` in loader args |
83
83
  | `next/link` `<Link>` | `<Link route="...">` or plain `<a>` | Prefer typed `<Link>` for known app routes after `pracht typegen`; plain anchors still work |
84
84
  | `next/link` `prefetch={false}` | `<Link prefetch="none">` | Pracht prefetches on hover/focus by default; also `"viewport"`, `"render"` |
85
85
  | `useLinkStatus()` / pending UI | `useNavigation()` | `{ state, location, formData }` — powers progress bars and optimistic UI |
86
86
  | `next/image` | `<Image>` from `@pracht/image` | Responsive srcsets plus Node, Cloudflare, Vercel, or passthrough loaders |
87
87
  | `next/head` or Metadata API | `head()` export on route/shell | Per-route and per-shell head merging |
88
+ | `next/script` `<Script>` | `<Script>` from `@pracht/core` | Strategies: `beforeHydration` (≈ `beforeInteractive`), `afterHydration` (≈ `afterInteractive`, default), `idle` (≈ `lazyOnload`), `visible` |
88
89
  | `className` | `class` | Preact uses `class` attribute |
89
90
  | `React.useState` etc. | `import { useState } from "preact/hooks"` | Preact hooks API is compatible |
90
91
  | `React.useEffect` | `import { useEffect } from "preact/hooks"` | Same API |
@@ -117,7 +118,7 @@ For pages router projects, you can **skip manual manifest wiring entirely** (Pha
117
118
  3. Update `package.json`:
118
119
  - Replace `react`, `react-dom` → `preact`
119
120
  - Replace `next` → `@pracht/core` (framework runtime), `@pracht/cli` (provides the `pracht` bin), `@pracht/vite-plugin`, and `@pracht/adapter-node` (or target adapter). There is no package named `pracht`.
120
- - If the app imports `next/image`, add `@pracht/image`; add `sharp` only for the built-in Node optimization endpoint.
121
+ - If the app imports `next/image`, add `@pracht/image`; add `sharp` only for the built-in Node optimization endpoint or build-time `?pracht` imports (static imports / blur placeholders).
121
122
  - Update scripts: `dev` → `pracht dev`, `build` → `pracht build`, `start` → `node dist/server/server.js` (Node.js) or a platform-specific deploy command; add `preview` → `pracht preview` to serve the production build locally
122
123
  4. Remove Next.js config files: `next.config.*`, `next-env.d.ts`, `.next/`
123
124
  5. If `tsconfig.json` has `"jsx": "preserve"`, change to `"jsx": "react-jsx"` and add `"jsxImportSource": "preact"`.
@@ -415,6 +416,14 @@ Choose the loader for the deployment target:
415
416
  Preserve the original `width`, `height`, `fill`, `sizes`, `quality`, and
416
417
  priority intent. See `docs/IMAGES.md` for the endpoint and loader wiring.
417
418
 
419
+ Static imports and blur placeholders migrate too: replace
420
+ `import photo from "./photo.jpg"` with `import photo from "./photo.jpg?pracht"`,
421
+ add `prachtImage()` (from `@pracht/image/vite`) to the Vite plugins, reference
422
+ the `@pracht/image/client` types once in a `.d.ts`, and keep
423
+ `<Image src={photo} placeholder="blur" />` as-is — the import supplies
424
+ `width`/`height`/`blurDataURL` exactly like Next's static imports. Pracht's
425
+ blur is CSS-only (no fade animation, no inline event handlers).
426
+
418
427
  #### `useRouter` → navigation
419
428
 
420
429
  ```tsx
@@ -443,13 +452,13 @@ async function createPost(formData: FormData) {
443
452
  }
444
453
 
445
454
  // Pracht — API route handler
446
- import type { ApiRouteArgs } from "@pracht/core";
455
+ import { withBase, type ApiRouteArgs } from "@pracht/core";
447
456
 
448
457
  export async function POST({ request }: ApiRouteArgs) {
449
458
  const form = await request.formData();
450
459
  await db.insert({ title: form.get("title") });
451
460
  // revalidatePath("/posts") equivalent: regenerate the ISG page on demand
452
- await fetch(new URL("/__pracht/revalidate", request.url), {
461
+ await fetch(new URL(withBase("/__pracht/revalidate"), request.url), {
453
462
  method: "POST",
454
463
  headers: {
455
464
  authorization: `Bearer ${process.env.PRACHT_REVALIDATE_TOKEN}`,
@@ -459,7 +468,7 @@ export async function POST({ request }: ApiRouteArgs) {
459
468
  });
460
469
  return new Response(null, {
461
470
  status: 303,
462
- headers: { location: "/posts" },
471
+ headers: { location: withBase("/posts") },
463
472
  });
464
473
  }
465
474
  ```
@@ -502,7 +511,8 @@ export async function loader({ request }: LoaderArgs) {
502
511
  | `next/image` | `@pracht/image` |
503
512
  | `react` | `preact` |
504
513
  | `react-dom` | `preact` |
505
- | `@next/font` | CSS `@font-face` or `fontsource` packages |
514
+ | `next/font/local` | `defineFont()` from `@pracht/core` register via `head() { return { fonts: [font] } }`, use `font.className`/`font.style` in components |
515
+ | `next/font/google` | Download the woff2 files into `public/fonts/` (e.g. via google-webfonts-helper), then `defineFont()` — pracht never fetches fonts at build time |
506
516
  | `@next/mdx` | `@mdx-js/rollup` (Vite plugin) |
507
517
  | `next-auth` | Direct integration in middleware/loaders |
508
518
  | `next/og` | `@vercel/og` or custom solution |
@@ -28,7 +28,7 @@ The user will describe a symptom (error, unexpected behavior, blank page, etc.).
28
28
  Before deep manual inspection, prefer running `pracht verify` (add `--changed` to scope the checks to git-changed files) for a fast agent loop or `pracht doctor` when the problem could be caused by broader broken app wiring or missing files.
29
29
  When another agent/tool needs the framework's resolved graph, prefer `pracht inspect routes --json`, `pracht inspect api --json`, or `pracht inspect build --json` over reconstructing it from source files. Prerequisites: `pracht inspect` needs the pracht plugin registered in the project's vite config, and `pracht inspect build` needs a prior `pracht build`.
30
30
  If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_routes`/`inspect_api`/`doctor`/`verify` MCP tools over shelling out — same payloads, structured results.
31
- While the dev server is running, `GET /_pracht` serves a devtools page with the same resolved route/API graph (raw JSON at `/_pracht.json`) — useful when you have a browser or `curl` handy but no CLI access. Dev SSR responses also carry a `Server-Timing` header (`mw`, `loader`, `render` durations in ms) — check it in the browser Network panel or with `curl -sI` to see which phase makes a route slow.
31
+ While the dev server is running, `GET /_pracht` serves a devtools page with the same resolved route/API graph (raw JSON at `/_pracht.json`) — useful when you have a browser or `curl` handy but no CLI access. Under a Vite deploy base, prefix both paths with that base; links from the devtools and dev-404 pages already do so. Dev SSR responses also carry a `Server-Timing` header (`mw`, `loader`, `render` durations in ms) — check it in the browser Network panel or with `curl -sI` to see which phase makes a route slow.
32
32
 
33
33
  ## Iron Law
34
34
 
@@ -1,12 +1,14 @@
1
1
  ---
2
2
  name: pracht-deploy
3
- version: 1.1.0
3
+ version: 1.2.0
4
4
  description: |
5
5
  Pracht deployment guide. Walks through adapter configuration, building, and
6
- deploying to Node.js, Cloudflare Workers, or Vercel. Handles wrangler config,
7
- Docker and production checklist.
6
+ deploying to Node.js, Cloudflare Workers, Netlify, Vercel, or a pure static
7
+ host. Handles platform config, Docker and production checklist.
8
8
  Use when asked to "deploy", "set up deployment", "configure adapter",
9
- "deploy to cloudflare", "deploy to vercel", or "production build".
9
+ "deploy to cloudflare", "deploy to netlify", "deploy to vercel", "static
10
+ export", or
11
+ "production build".
10
12
  allowed-tools:
11
13
  - Bash
12
14
  - Read
@@ -34,7 +36,9 @@ If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_build`
34
36
  | ------------------ | ---------------------------- | ------ |
35
37
  | Node.js | `@pracht/adapter-node` | Stable |
36
38
  | Cloudflare Workers | `@pracht/adapter-cloudflare` | Stable |
39
+ | Netlify | `@pracht/adapter-netlify` | Stable |
37
40
  | Vercel | `@pracht/adapter-vercel` | Stable |
41
+ | Static export | `@pracht/adapter-static` | Stable |
38
42
 
39
43
  ---
40
44
 
@@ -47,9 +51,35 @@ If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_build`
47
51
  ```ts
48
52
  import { pracht } from "@pracht/vite-plugin";
49
53
  import { nodeAdapter } from "@pracht/adapter-node";
50
- export default { plugins: [pracht({ adapter: nodeAdapter() })] };
54
+ export default {
55
+ plugins: [
56
+ pracht({
57
+ adapter: nodeAdapter({ canonicalOrigin: "https://app.example.com" }),
58
+ }),
59
+ ],
60
+ };
51
61
  ```
52
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
+
53
83
  ### Build
54
84
 
55
85
  ```bash
@@ -105,6 +135,25 @@ npx wrangler deploy
105
135
 
106
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`.
107
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
+ ```bash
150
+ pracht build
151
+ npx wrangler dev --config wrangler.local.jsonc --port 3000
152
+ ```
153
+
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
+
108
157
  ### Wrangler Configuration
109
158
 
110
159
  ```jsonc
@@ -112,7 +161,7 @@ To smoke-test the built worker locally first, run `pracht preview` — it builds
112
161
  {
113
162
  "name": "my-pracht-app",
114
163
  "main": "dist/server/worker.js",
115
- "compatibility_date": "2024-01-01",
164
+ "compatibility_date": "2026-04-06",
116
165
  "assets": {
117
166
  "binding": "ASSETS",
118
167
  "directory": "dist/client",
@@ -132,12 +181,33 @@ export async function loader({ context }: LoaderArgs) {
132
181
  }
133
182
  ```
134
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
+
135
190
  ### Custom Assets Binding
136
191
 
137
192
  ```ts
138
193
  pracht({ adapter: cloudflareAdapter({ assetsBinding: "STATIC" }) });
139
194
  ```
140
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`:
203
+
204
+ ```ts
205
+ cloudflareAdapter({
206
+ workerExportsFrom: "/src/cloudflare.ts",
207
+ workerHandlersFrom: "/src/worker-handlers.ts",
208
+ });
209
+ ```
210
+
141
211
  ### ISG via Workers Caching
142
212
 
143
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:
@@ -155,7 +225,8 @@ Before enabling it, audit ISG URLs for unbounded query strings. Workers Caching
155
225
  keys the exact path and query string, including parameter order and trailing
156
226
  slashes; use a bounded query allowlist/canonical redirect or an uncached gateway
157
227
  with a pathname-only `cf.cacheKey`, and normalize `Accept` there for routes that
158
- export markdown. See `docs/ADAPTERS.md#cache-key-cardinality`.
228
+ export markdown or declare `markdown: true` for middleware-owned negotiation.
229
+ See `docs/ADAPTERS.md#cache-key-cardinality`.
159
230
 
160
231
  Time-revalidated ISG pages then render on demand, are cached at the edge for
161
232
  their `revalidate` window (stale pages served instantly while the Worker
@@ -165,6 +236,64 @@ build-time snapshots and the worker-managed path either way.
165
236
 
166
237
  ---
167
238
 
239
+ ## Netlify Deployment
240
+
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"
255
+
256
+ [functions]
257
+ directory = "netlify/functions"
258
+ ```
259
+
260
+ ### Build, Preview, and Deploy
261
+
262
+ ```bash
263
+ npx pracht build && npx netlify dev
264
+ npx netlify deploy --build --prod
265
+ ```
266
+
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.
294
+
295
+ ---
296
+
168
297
  ## Vercel Deployment
169
298
 
170
299
  ### Setup
@@ -186,6 +315,96 @@ npx vercel deploy --prebuilt
186
315
 
187
316
  Produces: `.vercel/output/config.json`, `.vercel/output/static/`, `.vercel/output/functions/render.func/server.js`
188
317
 
318
+ There is no faithful local Vercel production runtime, so `pracht preview`
319
+ exits with guidance. Use `vercel build` or `vercel dev`. Set
320
+ `PRACHT_REVALIDATE_TOKEN` at build time when using webhook revalidation; its
321
+ 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.
326
+
327
+ ---
328
+
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
367
+
368
+ ```bash
369
+ pracht build # dist/client/ is the whole deployment
370
+ pracht preview # local static file server over dist/client/
371
+ ```
372
+
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.
407
+
189
408
  ---
190
409
 
191
410
  ## Deployment Checklist
@@ -201,7 +420,7 @@ Produces: `.vercel/output/config.json`, `.vercel/output/static/`, `.vercel/outpu
201
420
 
202
421
  1. Read `vite.config.ts` and `package.json` before giving advice.
203
422
  2. Run `pracht build` to verify the build succeeds before deploying.
204
- 3. Smoke-test the production runtime before pushing to production. For Node.js and Cloudflare, run `pracht preview`.
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`.
205
424
  4. If the user needs an adapter that isn't installed, help them add it (`pnpm add @pracht/adapter-*`).
206
425
  5. Don't push to production without the user's explicit confirmation.
207
426