create-pracht 0.4.2 → 0.5.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/README.md CHANGED
@@ -17,10 +17,15 @@ npm run dev
17
17
  - Detects the active package manager from the current environment.
18
18
  - Lets the user choose between the Node.js, Cloudflare, and Vercel adapters.
19
19
  - Optionally wires up Tailwind CSS (`tailwindcss` + `@tailwindcss/vite`, a global stylesheet, and the shell import).
20
- - Scaffolds a minimal app with a route manifest or pages router, shell, home route, sample API route, runnable project README, TypeScript typecheck script, and agent instructions.
20
+ - Scaffolds a minimal app with a route manifest or pages router, shell, home route, not-found page, sample API route, runnable project README, TypeScript typecheck script, and (with agent tooling enabled) agent instructions.
21
21
  - Manifest scaffolds include a commented-out `constraints` example in `src/routes.ts`, ready for `pracht verify`.
22
22
  - The generated `.gitignore` keeps `.pracht/app-graph.json` committable, and the README and agent instructions cover the `pracht verify` / `pracht plan` / `pracht report` loop.
23
- - Seeds the pracht Claude Code skills into `.claude/skills/` and writes a `.mcp.json` registering the `pracht mcp` server (yes-default prompt; skipped with `--no-agent-tools`).
23
+ - Every standalone pnpm scaffold includes a narrow lifecycle-script policy for
24
+ its required native dependencies in `pnpm-workspace.yaml`:
25
+ `onlyBuiltDependencies` for pnpm 10 or `allowBuilds` for pnpm 11. All adapters
26
+ allow `esbuild`, Cloudflare also allows `workerd`, and Tailwind starters also
27
+ allow `@tailwindcss/oxide`.
28
+ - Seeds the pracht Claude Code skills into `.claude/skills/`, writes a `.mcp.json` registering the `pracht mcp` server, and writes `AGENTS.md` (yes-default prompt; all of it skipped with `--no-agent-tools`, which leaves a project with no agent files at all).
24
29
  - Initializes a git repository with an initial commit (skipped with `--no-git`, when git is unavailable, or when the target is already inside a repository).
25
30
  - `--dry-run` uses pinned fallback versions and does not require npm registry access.
26
31
 
@@ -30,17 +35,18 @@ npm run dev
30
35
  node ./packages/start/bin/create-pracht.js
31
36
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --skip-install
32
37
  node ./packages/start/bin/create-pracht.js my-app --adapter=vercel --skip-install
38
+ node ./packages/start/bin/create-pracht.js my-app --adapter=netlify --skip-install
33
39
  node ./packages/start/bin/create-pracht.js my-app --template=tailwind --yes
34
40
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind --no-git --yes
35
41
  ```
36
42
 
37
43
  ## Options
38
44
 
39
- - `--adapter=node|cf|vercel` — choose the hosting adapter (default: node).
45
+ - `--adapter=node|cf|netlify|vercel` — choose the hosting adapter (default: node).
40
46
  - `--router=manifest|pages` — choose the routing system (default: manifest).
41
47
  - `--template=minimal|tailwind` — non-interactive template selection; `minimal` is the default output, `tailwind` is minimal plus Tailwind CSS wiring.
42
48
  - `--tailwind` / `--no-tailwind` — enable or disable Tailwind CSS without going through the prompt.
43
- - `--agent-tools` / `--no-agent-tools` — seed the Claude Code skills and `.mcp.json` (or skip them) without going through the prompt.
49
+ - `--agent-tools` / `--no-agent-tools` — seed the Claude Code skills, `.mcp.json`, and `AGENTS.md`/`CLAUDE.md` (or skip all of them) without going through the prompt.
44
50
  - `--no-git` — skip `git init` and the initial commit.
45
51
  - `--skip-install` — skip dependency installation.
46
52
  - `--yes`, `-y` — accept defaults (node adapter, manifest router, no Tailwind, agent tooling on) and skip all prompts.
@@ -53,11 +59,14 @@ node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind -
53
59
  - `vite.config.ts`
54
60
  - `src/routes.ts`
55
61
  - `src/routes/home.tsx`
62
+ - `src/routes/not-found.tsx` — the app's 404 page, wired via `notFound` in the manifest (pages scaffolds get `src/pages/404.tsx`, which pracht wires automatically)
56
63
  - `src/shells/public.tsx`
57
64
  - `src/api/health.ts`
58
65
  - `.gitignore`
59
66
  - `.claude/skills/<name>/SKILL.md` — the pracht agent skills (unless `--no-agent-tools`)
60
67
  - `.mcp.json` — registers the `pracht mcp` server for MCP clients (unless `--no-agent-tools`)
68
+ - `AGENTS.md` (plus a `CLAUDE.md` symlink pointing at it) — project conventions for coding
69
+ agents (unless `--no-agent-tools`; `README.md` documents the same commands for humans)
61
70
 
62
71
  The skills are copied from the repo's [skills/](../../skills/README.md) directory into this
63
72
  package at build/publish time (`scripts/sync-skills.js`), so the published npm tarball is
@@ -76,6 +85,12 @@ Cloudflare scaffolds also include:
76
85
 
77
86
  - `wrangler.jsonc`
78
87
 
88
+ Standalone pnpm scaffolds for every adapter include `pnpm-workspace.yaml` with
89
+ the version-appropriate lifecycle policy. When the new app belongs to an
90
+ ancestor pnpm workspace, that workspace owns the policy instead: the generated
91
+ README and completion message list the exact entries to add and no nested
92
+ workspace file is created.
93
+
79
94
  ## Generated Scripts
80
95
 
81
96
  - `dev` -> `pracht dev`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pracht",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -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
@@ -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
@@ -89,9 +95,11 @@ curl -X POST https://example.com/__pracht/revalidate \
89
95
  or wrong. Providers that can't send bearer auth may use the
90
96
  `x-pracht-revalidate-token` header instead.
91
97
  - 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.
98
+ `revalidated` / `skipped` / `failed` arrays plus a `details` array naming why
99
+ each path was skipped (`not_a_route`, `not_isg`, `not_prerendered`,
100
+ `no_webhook_policy`) or failed — check `details` first when a webhook appears
101
+ to do nothing. Failed paths keep serving the previous copy. Regeneration is
102
+ single-flighted per path and never replays the caller's cookies/auth headers.
95
103
 
96
104
  ## Step 4: Adapter mechanics
97
105
 
@@ -100,7 +108,7 @@ curl -X POST https://example.com/__pracht/revalidate \
100
108
  | Node | File mtime vs window; serves stale, refreshes in background | Regenerates the on-disk HTML synchronously |
101
109
  | Cloudflare (default) | Worker-managed Cache API timestamp, `env.ASSETS` fallback — **per colo** | Overwrites the Cache API entry in the receiving colo only |
102
110
  | 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) |
111
+ | 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
112
 
105
113
  Cloudflare specifics (`docs/ADAPTERS.md#isg-via-workers-caching-cache`):
106
114
 
@@ -127,8 +135,9 @@ exact path **plus query string** (param order and trailing slash included), so
127
135
  revalidation — and attacker-chosen query values create unbounded cold entries.
128
136
  Before enabling `cache: true`, canonicalize or reject stray query params (the
129
137
  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
138
+ `markdown` or declaring middleware-owned `markdown: true` carry `Vary: Accept`,
139
+ which multiplies variants per `Accept` string. Vercel prerender functions are
140
+ generated with `allowQuery: []`, so
132
141
  query strings do not fragment that cache. Middleware never runs for cached ISG
133
142
  hits on any adapter — keep per-visitor logic on SSR routes.
134
143
 
@@ -158,9 +167,15 @@ pracht typegen # if src/routes.ts changed
158
167
 
159
168
  1. Never overwrite `wrangler.jsonc`/`wrangler.toml` or `vercel.json` — diff
160
169
  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.
170
+ 2. Never propose ISG for personalized responses. ISG HTML renders always run
171
+ on a sanitized request (`GET`, `Accept: text/html`, path only — no cookies,
172
+ credentials, query, or body); Cloudflare Workers Caching uses the same
173
+ isolation with `Accept: text/markdown` for its markdown cache variant. A
174
+ loader that reads the session therefore sees an anonymous visitor. On top of that, `Set-Cookie` or
175
+ `Cache-Control: private`/`no-store` output fails regeneration on Node and
176
+ Cloudflare (on Vercel the credential headers are stripped and the mismatch
177
+ is logged), and `Vary: Cookie`/`Authorization`/`*` is kept out of shared
178
+ caches by design.
164
179
  3. Always pair `render: "isg"` with an explicit `revalidate` policy — without
165
180
  one the route silently behaves like SSG.
166
181
  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
 
@@ -85,6 +85,7 @@ For pages router projects, you can **skip manual manifest wiring entirely** (Pha
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
@@ -3,10 +3,11 @@ name: pracht-deploy
3
3
  version: 1.1.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, or Vercel. Handles platform
7
+ 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", or
10
+ "production build".
10
11
  allowed-tools:
11
12
  - Bash
12
13
  - Read
@@ -34,6 +35,7 @@ If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_build`
34
35
  | ------------------ | ---------------------------- | ------ |
35
36
  | Node.js | `@pracht/adapter-node` | Stable |
36
37
  | Cloudflare Workers | `@pracht/adapter-cloudflare` | Stable |
38
+ | Netlify | `@pracht/adapter-netlify` | Stable |
37
39
  | Vercel | `@pracht/adapter-vercel` | Stable |
38
40
 
39
41
  ---
@@ -47,9 +49,20 @@ If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_build`
47
49
  ```ts
48
50
  import { pracht } from "@pracht/vite-plugin";
49
51
  import { nodeAdapter } from "@pracht/adapter-node";
50
- export default { plugins: [pracht({ adapter: nodeAdapter() })] };
52
+ export default {
53
+ plugins: [
54
+ pracht({
55
+ adapter: nodeAdapter({ canonicalOrigin: "https://app.example.com" }),
56
+ }),
57
+ ],
58
+ };
51
59
  ```
52
60
 
61
+ Pin `canonicalOrigin` in production so `request.url` does not depend on the
62
+ incoming `Host` header. `maxBodySize` is also available on `nodeAdapter()`.
63
+ Only custom entries behind a trusted proxy that overwrites forwarded headers
64
+ should use `createNodeRequestHandler({ trustProxy: true })`.
65
+
53
66
  ### Build
54
67
 
55
68
  ```bash
@@ -105,6 +118,25 @@ npx wrangler deploy
105
118
 
106
119
  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
120
 
121
+ Wrangler owns the Worker's binding environment. Put local-only secrets such as
122
+ `PRACHT_CONFIRMATION_SECRET` and `PRACHT_REVALIDATE_TOKEN` in a gitignored
123
+ `.dev.vars`; prefixing the host command with those variables does not
124
+ automatically expose them inside the Worker. Keep production values in
125
+ `wrangler secret`.
126
+
127
+ If the config contains a custom-domain route, preview can listen on localhost
128
+ while `request.url` inside the Worker uses the custom domain. Sign that
129
+ effective `@authority` for Web Bot Auth or temporarily disable the route. To use
130
+ a separate local config, build first, then run:
131
+
132
+ ```bash
133
+ pracht build
134
+ npx wrangler dev --config wrangler.local.jsonc --port 3000
135
+ ```
136
+
137
+ The local config must keep `main: "dist/server/worker.js"` and omit the
138
+ production route. `pracht preview` does not forward Wrangler's `--config` flag.
139
+
108
140
  ### Wrangler Configuration
109
141
 
110
142
  ```jsonc
@@ -112,7 +144,7 @@ To smoke-test the built worker locally first, run `pracht preview` — it builds
112
144
  {
113
145
  "name": "my-pracht-app",
114
146
  "main": "dist/server/worker.js",
115
- "compatibility_date": "2024-01-01",
147
+ "compatibility_date": "2026-04-06",
116
148
  "assets": {
117
149
  "binding": "ASSETS",
118
150
  "directory": "dist/client",
@@ -132,12 +164,33 @@ export async function loader({ context }: LoaderArgs) {
132
164
  }
133
165
  ```
134
166
 
167
+ Keep Cloudflare binding reads inside the loader, API handler, capability
168
+ `run()`, or another request-time function. Although Workers permits top-level
169
+ `env.MY_KV`, Pracht graph inspection intentionally fails such module-initializer
170
+ reads because it cannot supply an authoritative binding without risking false
171
+ graph metadata.
172
+
135
173
  ### Custom Assets Binding
136
174
 
137
175
  ```ts
138
176
  pracht({ adapter: cloudflareAdapter({ assetsBinding: "STATIC" }) });
139
177
  ```
140
178
 
179
+ ### Named bindings and default-export handlers
180
+
181
+ Durable Object and Workflow classes are named Worker exports. Re-export them
182
+ from the module configured with `workerExportsFrom`. Queue consumers, Cron
183
+ Triggers, and Email Routing are instead methods on the default export; expose
184
+ named `queue`, `scheduled`, or `email` functions from the module configured
185
+ with `workerHandlersFrom`:
186
+
187
+ ```ts
188
+ cloudflareAdapter({
189
+ workerExportsFrom: "/src/cloudflare.ts",
190
+ workerHandlersFrom: "/src/worker-handlers.ts",
191
+ });
192
+ ```
193
+
141
194
  ### ISG via Workers Caching
142
195
 
143
196
  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 +208,8 @@ Before enabling it, audit ISG URLs for unbounded query strings. Workers Caching
155
208
  keys the exact path and query string, including parameter order and trailing
156
209
  slashes; use a bounded query allowlist/canonical redirect or an uncached gateway
157
210
  with a pathname-only `cf.cacheKey`, and normalize `Accept` there for routes that
158
- export markdown. See `docs/ADAPTERS.md#cache-key-cardinality`.
211
+ export markdown or declare `markdown: true` for middleware-owned negotiation.
212
+ See `docs/ADAPTERS.md#cache-key-cardinality`.
159
213
 
160
214
  Time-revalidated ISG pages then render on demand, are cached at the edge for
161
215
  their `revalidate` window (stale pages served instantly while the Worker
@@ -165,6 +219,60 @@ build-time snapshots and the worker-managed path either way.
165
219
 
166
220
  ---
167
221
 
222
+ ## Netlify Deployment
223
+
224
+ ### Setup
225
+
226
+ 1. Ensure `@pracht/adapter-netlify` and `netlify-cli` are installed.
227
+ 2. In `vite.config.ts`:
228
+ ```ts
229
+ import { pracht } from "@pracht/vite-plugin";
230
+ import { netlifyAdapter } from "@pracht/adapter-netlify";
231
+ export default { plugins: [pracht({ adapter: netlifyAdapter() })] };
232
+ ```
233
+ 3. Add `netlify.toml`:
234
+ ```toml
235
+ [build]
236
+ command = "pnpm build"
237
+ publish = "dist/client"
238
+
239
+ [functions]
240
+ directory = "netlify/functions"
241
+ ```
242
+
243
+ ### Build, Preview, and Deploy
244
+
245
+ ```bash
246
+ npx pracht build && npx netlify dev
247
+ npx netlify deploy --build --prod
248
+ ```
249
+
250
+ The build emits `netlify/functions/pracht.mjs`. Page requests go through that
251
+ function so Markdown negotiation and route-state requests remain correct;
252
+ hashed assets bypass it and stay outside the function bundle. The generated
253
+ config enumerates only client files the function can serve and roots matching
254
+ exclusions at the function file so Netlify's tracer cannot re-add bypassed
255
+ trees. Netlify durable caching
256
+ implements time-based ISG and per-path cache tags implement authenticated
257
+ webhook revalidation. A trailing-slash ISG document request permanently
258
+ redirects to the canonical slashless URL before rendering, and webhook
259
+ revalidation normalizes either spelling before purging the cache tag.
260
+ Only `Cache-Control`, `CDN-Cache-Control`, and `Netlify-CDN-Cache-Control`
261
+ override the adapter's cache defaults; provider-specific headers for another
262
+ CDN do not. Set a cache window to `0` to disable stale serving or freshness.
263
+ `Netlify-Vary` owns route-state variants, while the standard `Vary: Accept`
264
+ header owns Markdown negotiation. Cacheable negotiated SSG representations use
265
+ the same `Netlify-Vary` instructions as their prerendered HTML. Shared ISG
266
+ renders strip visitor-specific request data and Netlify context metadata before
267
+ loaders or context factories run.
268
+
269
+ `pracht preview` exits with guidance because it cannot emulate Netlify's
270
+ Functions and CDN behavior. Build the generated function before using
271
+ `netlify dev` for the platform-shaped local runtime. Configure
272
+ `PRACHT_REVALIDATE_TOKEN` in Netlify when webhook revalidation is enabled.
273
+
274
+ ---
275
+
168
276
  ## Vercel Deployment
169
277
 
170
278
  ### Setup
@@ -186,6 +294,15 @@ npx vercel deploy --prebuilt
186
294
 
187
295
  Produces: `.vercel/output/config.json`, `.vercel/output/static/`, `.vercel/output/functions/render.func/server.js`
188
296
 
297
+ There is no faithful local Vercel production runtime, so `pracht preview`
298
+ exits with guidance. Use `vercel build` or `vercel dev`. Set
299
+ `PRACHT_REVALIDATE_TOKEN` at build time when using webhook revalidation; its
300
+ Vercel bypass token is embedded in `.prerender-config.json`. Rename the main
301
+ Edge Function with `vercelAdapter({ functionName })` if its default `render`
302
+ name would collide with an ISG route. Custom entries must export the
303
+ `nodeListener` created by `createVercelNodeListener(handle)` for Node ISR
304
+ functions.
305
+
189
306
  ---
190
307
 
191
308
  ## Deployment Checklist
@@ -201,7 +318,7 @@ Produces: `.vercel/output/config.json`, `.vercel/output/static/`, `.vercel/outpu
201
318
 
202
319
  1. Read `vite.config.ts` and `package.json` before giving advice.
203
320
  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`.
321
+ 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
322
  4. If the user needs an adapter that isn't installed, help them add it (`pnpm add @pracht/adapter-*`).
206
323
  5. Don't push to production without the user's explicit confirmation.
207
324
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pracht-test-api
3
- version: 1.1.0
3
+ version: 1.2.0
4
4
  description: |
5
5
  Auto-generate Vitest request/response tests for every handler in `src/api/`.
6
6
  Each test instantiates a `Request`, calls the exported HTTP method handler
@@ -53,34 +53,28 @@ Ask the user which subset to scaffold or accept paths via `$ARGUMENTS`.
53
53
  ## Step 3: Generate one test per handler file
54
54
 
55
55
  Place tests next to the handler with `.test.ts` suffix
56
- (`src/api/users/[id].test.ts`). Use a small helper to construct
57
- `ApiRouteArgs`:
56
+ (`src/api/users/[id].test.ts`). Use `createApiArgs()` from `@pracht/test`
57
+ (add it as a dev dependency if missing) to construct `ApiRouteArgs`:
58
58
 
59
59
  ```ts
60
60
  import { describe, it, expect } from "vitest";
61
+ import { createApiArgs, readJson } from "@pracht/test";
61
62
  import { GET, POST /* import only what the handler exports */ } from "./<file>";
62
63
 
63
- function args(url: string, init?: RequestInit, params: Record<string, string> = {}) {
64
- const request = new Request(url, init);
65
- return {
66
- request,
67
- params,
68
- context: {} as never,
69
- url: new URL(request.url),
70
- signal: AbortSignal.timeout(5000),
71
- route: {} as never,
72
- };
73
- }
74
-
75
64
  describe("<METHOD> <api-path>", () => {
76
65
  it("returns 200 on a valid request", async () => {
77
- const res = await GET(args("http://localhost<api-path>"));
66
+ const res = await GET(createApiArgs({ url: "<api-path>" }));
78
67
  expect(res).toBeInstanceOf(Response);
79
68
  expect(res.status).toBe(200);
80
69
  });
81
70
  });
82
71
  ```
83
72
 
73
+ Pass `params` for dynamic segments, `body` for JSON payloads (plain objects
74
+ are JSON-encoded with the right `Content-Type`), and use `submitForm()` for
75
+ form-encoded/multipart POSTs — it drives the same `FormData` parsing path
76
+ `defineApi()` uses.
77
+
84
78
  ## Step 4: Generate method-specific cases
85
79
 
86
80
  For each exported method, emit the smallest realistic case:
@@ -108,7 +102,7 @@ middleware is in that list. If it is, scaffold an extra test:
108
102
 
109
103
  ```ts
110
104
  it("rejects unauthenticated requests", async () => {
111
- const res = await POST(args("http://localhost<api-path>", { method: "POST" }));
105
+ const res = await POST(createApiArgs({ url: "<api-path>", method: "POST" }));
112
106
  expect([401, 403, 302]).toContain(res.status);
113
107
  });
114
108
  ```
@@ -127,9 +121,9 @@ For handlers that return `Response.json(...)`, generate:
127
121
 
128
122
  ```ts
129
123
  it("returns JSON with the expected keys", async () => {
130
- const res = await GET(args("http://localhost<api-path>"));
124
+ const res = await GET(createApiArgs({ url: "<api-path>" }));
131
125
  expect(res.headers.get("content-type")).toMatch(/application\/json/);
132
- const body = await res.json();
126
+ const body = await readJson(res);
133
127
  expect(body).toEqual(expect.objectContaining({ /* fill in */ }));
134
128
  });
135
129
  ```
@@ -93,7 +93,11 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
93
93
  metadata (`buildTarget`, manifests, `resolvedApp`, ...) that `server.js`
94
94
  exports for the prerender pass.
95
95
  - `assets.directory` points to `dist/client`.
96
- - `compatibility_date` is set and recent.
96
+ - `compatibility_date` is set, and is a date the installed workerd supports.
97
+ It must not be *newer* than the runtime: workerd refuses to start with
98
+ "This Worker requires compatibility date X, but the newest date supported
99
+ by this server binary is Y". Never set it to today's date — that is by
100
+ construction at or beyond the newest released workerd.
97
101
  - Bindings declared in wrangler config for every `context.env.*` access in
98
102
  loaders, middleware, and API routes (grep, then cross-check).
99
103
  - **No Node-only APIs in the server bundle.** Grep the server files for:
@@ -134,11 +138,19 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
134
138
  - Required env vars are configured in the Vercel project (cannot verify from
135
139
  CLI without `vercel env pull` — run that and diff against `process.env.*`
136
140
  references).
137
- - Edge runtime constraints: pracht **always** writes the function's
138
- `.vc-config.json` with `runtime: "edge"` there is no Node runtime
139
- variant, so run the same Node-only API check as Cloudflare
140
- **unconditionally** for Vercel builds. Do not skip it based on a runtime
141
- probe.
141
+ - Edge runtime constraints: the render function's `.vc-config.json` is
142
+ **always** written with `runtime: "edge"`, so run the same Node-only API
143
+ check as Cloudflare **unconditionally** for Vercel builds. Do not skip it
144
+ based on a runtime probe ISG routes run the same bundle on Node, but any
145
+ Node-only API still breaks the edge function.
146
+ - ISG functions: every `<route>.prerender-config.json` must sit next to a
147
+ **Serverless** `<route>.func` (`.vc-config.json` with `launcherType:
148
+ "Nodejs"`). Vercel rejects a prerender config paired with an edge function:
149
+ `Unexpected function type "EdgeFunction" at path "<route>"`.
150
+ - Region configuration: `vercelAdapter({ regions: "all" })` is valid for the
151
+ Edge render function, but generated Node ISG function configs must omit
152
+ `regions` so the project's default Serverless region applies. Node configs
153
+ may only contain arrays of concrete region identifiers.
142
154
  - An API route importing `@pracht/image/node` is an error for the Vercel Edge
143
155
  function. Require `vercelLoader` (with aligned allowed sizes) or
144
156
  `passthroughLoader` instead.