pre-deploy

Adapter-aware pre-deployment checklist (Node, Cloudflare Workers, Vercel, static) for the failures that only surface in production: missing env vars, Node-only APIs in edge bundles, absent ISG manifes

SKILL.md

GitHub

Pracht Pre-Deploy

Run before every production deploy. Each adapter has a different runtime contract; enforce the one that matches the build. Never deploy on the user's behalf — end at the verdict.

MCP: when the pracht MCP server is registered (pracht dev-mcp), prefer its inspect_routes/inspect_api/inspect_build/doctor/verify tools over shelling out.

Step 1: Build, then detect the adapter

Always build first — never lint a stale dist/.

pracht build
pracht inspect build --json

adapterTarget is authoritative; vite.config.ts (nodeAdapter(), cloudflareAdapter(), vercelAdapter(), staticAdapter()) is the cross-check. Never assume the adapter.

Step 2: Framework-wide checks

pracht doctor --json
pracht verify --json
pracht typegen --check   # only if src/pracht-routes.ts or src/pracht.d.ts exists

These catch app-graph wiring problems independent of the adapter, including defineApp({ constraints }) violations and a stale .pracht/app-graph.json snapshot (fix that with pracht plan --write, then re-review the plan output). Resolve every status: "error" before continuing — if pracht doctor reports errors, stop here; the remaining checks will be noisy false positives. Stale generated typed-route files block deployment; if the app does not use them yet, note that typegen --check is optional.

If pracht inspect agents --json reports any WebMCP exposure, run the live browser boundary too. Point it at the already-started preview, or let it own the server lifecycle:

pracht verify webmcp --start "pracht preview" --json

CI must install a pinned Chrome 150+ build and pass its executable with --browser; the verifier never silently downloads one. The verifier drives Chrome through playwright-core, which the app must list as a dev dependency. Treat unsupported API, startup/registration failure, and graph drift as deployment errors. Do not add --scenario unless the repository already supplies an explicitly safe WebMCP eval scenario.

For a PR deploy, pracht report --base origin/main produces a markdown summary (graph diff + verify + budgets) worth attaching.

Step 3: Adapter checklist

Node (@pracht/adapter-node)

  • dist/server/server.js, dist/server/.vite/client-manifest.json, and — if any route is render: "isg" — dist/server/isg-manifest.json all exist.
  • pracht preview --skip-build (or node dist/server/server.js) boots and curl localhost:3000 returns 200.
  • Every env var the app reads (grep process.env.* across src/) is set in the deployment environment. List them for the user.
  • If the app mounts createImageHandler() from @pracht/image/node, sharp is installed and localOrigin is the same trusted public origin as nodeAdapter({ canonicalOrigin }). A relative image endpoint without both is an error in every environment; loopback-looking request origins are intentionally not trusted.
  • Reverse proxy / TLS termination configured — out of scope here, flag for confirmation.
  • If the proxy strips Vite's deploy base, nodeAdapter({ basePathStripped: true }) is set; application code should still observe the public base in request.url, and the proxy must own the bare-base redirect (/app → /app/).
  • If the app registers background work with args.waitUntil(), the process manager sends SIGTERM (not SIGKILL) on stop and its grace period exceeds nodeAdapter({ shutdownTimeoutMs }) (default 10s); otherwise that work is cut off on every deploy. A custom server entry must call handler.closeEventStreams() then handler.drain() itself.
  • Background work still reaching for context.executionContext.waitUntil or a platform context should move to the portable args.waitUntil() — the platform-specific call is undefined on every other adapter.

Cloudflare Workers (@pracht/adapter-cloudflare)

  • wrangler.toml/wrangler.jsonc present at repo root with an assets block binding ASSETS, and bindings declared for every context.env.* access in loaders, middleware, and API routes (grep, then cross-check). pracht build generates dist/server/wrangler.json from it with main: "worker.js", no_bundle, the ESModule rule, and the assets directory; confirm it exists after the build and that wrangler deploy prints "Using redirected Wrangler configuration".
  • If the config defines named environments, the deploy step must either build with CLOUDFLARE_ENV=<name> or deploy with --env <name> from a config that sets the keys below. Only then does a config need them:
    • main points at dist/server/worker.js — the deploy entry exporting only the default handler and Cloudflare entrypoint classes. Pointing it at dist/server/server.js is an error: workerd validates every named export of the deploy entry and rejects the build metadata (buildTarget, manifests, resolvedApp, …) that server.js exports for the prerender pass.
    • no_bundle: true plus an ESModule rule whose globs include "**/*.js", so Wrangler uploads Pracht's lazy server chunks as separate modules instead of folding them into the entry, and assets.directory: "dist/client".
  • compatibility_date is set and is a date the installed workerd supports. It must not be newer than the runtime, or workerd refuses to start ("This Worker requires compatibility date X, but the newest date supported by this server binary is Y"). Never set it to today's date — that is by construction at or beyond the newest released workerd.
  • No Node-only APIs in the server bundle. Check compatibility_flags first. Without nodejs_compat, pracht build already fails on any Node builtin left in the bundle (nodejs_als allows node:async_hooks), so grep for globals instead: process.cwd, Buffer, __dirname, __filename. With nodejs_compat (compatibility date 2024-09-23 or later) every node: import builds, but some modules are stubs that throw when called — flag calls into child_process, cluster, worker_threads and writes through fs. Dev already runs inside workerd via @cloudflare/vite-plugin, so this is the backstop for code paths dev never hits.
  • An API route importing @pracht/image/node is an error on Workers (its optimizer needs sharp). Require cloudflareLoader or passthroughLoader.
  • ISG works out of the box via the per-colo Workers Cache API. If time-revalidated routes should use the edge-tier Workers Caching upgrade, confirm both sides: cloudflareAdapter({ cache: true }) and "cache": { "enabled": true } in wrangler config. With it enabled, flag ISG routes reachable through unbounded query strings — require a bounded allowlist or canonical redirect, or an uncached gateway with a normalized cf.cacheKey — and check that markdown-capable routes normalize Accept at the gateway when variant fan-out matters.
  • Bundle size: measure what actually deploys — every .js file under dist/server/ (worker.js alone is a few lines; no_bundle uploads the pre-built module graph). npx wrangler deploy --dry-run prints the total. The Workers limit is ~1 MB compressed on free, ~10 MB on paid. Warn at 80% of the active limit.

Vercel (@pracht/adapter-vercel)

  • .vercel/output/config.json exists with version: 3, and .vercel/output/static/ is populated.
  • The render function exists at .vercel/output/functions/<functionName>.func/server.js. The name defaults to render but is configurable via vercelAdapter({ functionName }) — read it from vite.config.ts rather than hardcoding render.func.
  • Env vars are configured in the Vercel project. This cannot be verified from the CLI alone: run vercel env pull and diff against process.env.* uses.
  • Run the Cloudflare Node-only API check unconditionally. The render function's .vc-config.json is always written with runtime: "edge". Do not skip it based on a runtime probe — ISG routes run the same bundle on Node, but a Node-only API still breaks the edge function.
  • Every <route>.prerender-config.json sits next to a Serverless <route>.func (.vc-config.json with launcherType: "Nodejs"). Vercel rejects a prerender config paired with an edge function: Unexpected function type "EdgeFunction" at path "<route>".
  • vercelAdapter({ regions: "all" }) is valid for the Edge render function, but generated Node ISG function configs must omit regions so the project default applies; Node configs may only contain arrays of concrete region identifiers.
  • An API route importing @pracht/image/node is an error for the Edge function. Require vercelLoader (with aligned allowed sizes) or passthroughLoader.

Static export (@pracht/adapter-static)

There is no server to get wrong, so this checklist is about what the host must do and what the build cannot enforce.

  • dist/client/ exists and is the deploy root. dist/server/ is build tooling and must not be uploaded — it contains the prerender bundle.
  • The build is the gate: it fails closed on ssr/isg routes, SPA loaders, non-full SPA hydration, API routes, route/not-found middleware, network-exposed capabilities, and any Vite base that is not / or a root-absolute path. If pracht build succeeded, those contracts already hold — do not re-derive them by hand. Report a failing build verbatim; the message names the routes.
  • The host serves index.html for directory URLs. Confirm the actual setting: S3 website endpoints need an index document, nginx needs try_files $uri $uri/index.html; GitHub Pages and Netlify do it by default.
  • The host maps 404.html as its error document, or unknown URLs get the host's generic error page instead of the app's notFound route. Verify dist/client/404.html exists — if it does not, the app declares no notFound page: warn.
  • Security headers are not applied. Every other adapter sets the four default security headers at request time; a static host has no request runtime. dist/server/headers-manifest.json records what each route would have carried — mirror the ones you need into the host's own header config (_headers on Netlify, CloudFront response header policies, nginx add_header). error for any app handling user input, warn otherwise. HSTS and CSP are host-side decisions either way.
  • If staticAdapter({ fallback }) is configured, the host needs a rewrite of unmatched URLs to that file that does not shadow real files. It makes unknown URLs answer 200 (soft 404s), and without it the fallback file is inert — deep links into dynamic render: "spa" routes will 404.
  • Smoke test the real output, not the dev server: pracht preview --skip-build serves dist/client/ the way a dumb host would. Check /, one dynamic SSG path, one deep link into a SPA route, and one unknown URL.
  • A static host cannot negotiate Accept, so agents read an SSG page's Markdown at /<path>.md (written by the build for routes with a markdown export and no middleware). Check the host serves .md as text/markdown; charset=utf-8 — nginx's stock mime.types lacks it — and read the build note naming Markdown-capable pages that got no file.
  • Sub-path deploys (GitHub Pages project site, S3 key prefix) need Vite base set to that path (base: "/my-project/"), matching the deploy path exactly — a mismatch 404s every asset. Then confirm no hand-written root-absolute internal links: grep -rn 'href="/' src/ and check each hit is external, an asset under public/, or a <Link route>. Framework-owned URLs from @pracht/image's defaultLoader and the OpenAPI companion UI/document already carry the base — do not flag their base-free route declarations — but custom image loaders and OpenAPI provider asset URLs still need to match the intended host. CDN bases (https://cdn…) and document-relative bases ("" / "./") are build errors, not sub-path deploys.

Step 4: Cross-cutting

  • /audit-secrets — no process.env.* or context.env.* value flows into a loader return value.
  • /audit-headers — applyDefaultSecurityHeaders is in use on user-facing responses, or headers() exports cover the same ground. On a static export this moves entirely to the host config; see above.
  • git status is clean. Deploying uncommitted work is a footgun.

Step 5: Report

A checklist grouped by Framework, Adapter, Cross-cutting. Tag each item with a primary severity — error (blocks deploy), warn (proceeds but risky), info — keeping pass/fail as the secondary per-item status. End with one line: READY / BLOCKED (N errors) / READY WITH WARNINGS (N warnings).

For a static export, never report READY without naming the host settings the deploy depends on — clean URLs, 404.html, security headers, and the fallback rewrite if configured. The build cannot verify any of them, so an unqualified READY is the one way this skill can mislead.

$ARGUMENTS