Adapters bridge pracht's platform-agnostic core to specific deployment targets. Each adapter is a thin layer that translates between the platform's request handling and pracht's Web Request/Response interface.
Platform Request (e.g. Node IncomingMessage, CF Worker fetch)
→ Adapter converts to Web Request
→ Adapter checks: is this a static asset?
→ Yes: serve from dist/client/
→ No: is this a prerendered SSG/ISG page?
→ Yes: serve static HTML (check ISG staleness)
→ No: delegate to handlePrachtRequest()
→ Convert Web Response back to platform response
Every adapter implements this same flow. The differences are in how static files are served and how ISG revalidation state is tracked.
For page routes, adapters must preserve the distinction between document
requests and route-state fetches (x-pracht-route-state-request: 1 or
?_data=1). Cached or prerendered HTML should never satisfy a route-state
fetch, and HTML responses should vary on that header when both
representations can exist for the same URL. Prerendered HTML also carries
route and shell document headers from the build header manifest so static
responses match dynamic document responses.
Each adapter exports three things:
// Example: Node adapter
import { nodeAdapter } from "@pracht/adapter-node";
pracht({ adapter: nodeAdapter() });The factory returns a PrachtAdapter object that the Vite plugin uses to
generate the server entry module.
// Example: Node adapter
export function createNodeRequestHandler<TContext>(
options: NodeAdapterOptions<TContext>,
): (req: IncomingMessage, res: ServerResponse) => Promise<void>;export function createNodeServerEntryModule(options?: NodeServerEntryModuleOptions): string;The adapter factory calls the entry module generator internally to create a virtual module
(virtual:pracht/server) that bootstraps the server.
| Option | Type | Description |
|---|---|---|
app |
PrachtApp |
The resolved app from defineApp() |
registry |
ModuleRegistry |
Lazy module importers |
staticDir |
string |
Path to dist/client/ |
viteManifest |
ViteManifest |
Client asset manifest for injection |
createContext |
(args) => TContext |
App-level context factory |
trustProxy |
boolean |
Honor forwarded headers for URL construction (default: false) |
canonicalOrigin |
string |
Fixed public origin for request.url; ignores request Host values |
maxBodySize |
number |
Maximum request body size in bytes (default: 1 MiB) |
Set canonicalOrigin to pin request.url to your known public origin and
avoid depending on Host / forwarded host headers at all. Absolute-form
(http://...) and network-path (//...) request targets are normalized to
path/query/hash before resolving against the canonical origin:
createNodeRequestHandler({
app: resolvedApp,
registry,
staticDir,
canonicalOrigin: "https://app.example.com",
});Without canonicalOrigin, the Node adapter derives the request URL from the
socket: protocol is inferred from TLS state, and host from the Host header.
Forwarded headers (Forwarded, X-Forwarded-Proto, X-Forwarded-Host) are
ignored unless trustProxy: true is enabled. Built Node apps warn when no
canonicalOrigin is configured, because app code that reads request.url can
otherwise inherit attacker-controlled Host values in misconfigured
deployments.
Set trustProxy: true when the Node server sits behind a trusted reverse proxy
(nginx, Cloudflare, a load balancer, etc.) that sets forwarded headers:
createNodeRequestHandler({
app: resolvedApp,
registry,
staticDir,
trustProxy: true,
});When enabled, header precedence is:
- RFC 7239
Forwardedheader (proto=andhost=directives) X-Forwarded-Proto/X-Forwarded-Host- Socket-derived values (fallback)
Security note:
canonicalOriginis the safest option when your app usesrequest.urlto build absolute URLs. If you rely ontrustProxy, only enable it behind a proxy that overwrites forwarded headers.
- Static file serving: reads from
dist/client/with proper content-type headers. Hashed assets under/assets/getCache-Control: public, max-age=31536000, immutable; HTML and other files getpublic, max-age=0, must-revalidate. Clean URLs (e.g./about) resolve toabout/index.html. Prerendered HTML receives route and shell document headers fromdist/server/headers-manifest.json. SSG/ISG prerendering rejects dangerous document headers such asSet-Cookie,Authorization,Proxy-Authenticate,WWW-Authenticate, and secret-shaped customx-*headers before they can enter that manifest. - ISG revalidation: checks
isg-manifest.jsonfor time and webhook revalidation metadata. Time revalidation compares file mtime against the configured window, serves stale HTML immediately, and refreshes the file in the background. Webhook revalidation is exposed atPOST /__pracht/revalidateand regenerates named paths synchronously after authenticatingPRACHT_REVALIDATE_TOKEN. Route-state requests (x-pracht-route-state-requestand?_data=1) bypass the cached HTML path so client navigation still reacheshandlePrachtRequest(). All regeneration uses a clean HTML request instead of replaying the triggering user's cookies, authorization headers, locale, or experiment headers. Static and ISG files are streamed, and static responses supportETag/Last-Modifiedconditional revalidation. - Vite manifest: reads
.vite/manifest.jsonto inject correct<script>and<link>tags into server-rendered HTML. - Response headers: preserves multiple
Set-Cookieheaders from framework responses by writing them as an array to Node'sServerResponse.
Pracht's Node handler cannot serve WebSocket upgrades, and this is a property of
Node rather than a gap in the adapter: http.Server routes upgrade requests to
its upgrade event, not to the request handler, so a handshake never reaches
handler(req, res) at all. (With no upgrade listener attached, Node closes
those connections.)
Attach a WebSocket server to the same HTTP server instead. The generated entry
only calls createServer() when it is the process entrypoint, so importing
handler and building the server yourself is supported:
import { createServer } from "node:http";
import { WebSocketServer } from "ws";
import { handler } from "./dist/server/server.js";
const server = createServer(handler); // pracht serves ordinary requests
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (req, socket, head) => {
// Check the Origin header yourself here: browsers do not apply CORS to
// WebSocket, so a cross-site page can otherwise open an authenticated
// socket (cross-site WebSocket hijacking). Pracht's own
// `api.requireSameOrigin` check cannot help — this never reaches pracht.
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
});
server.listen(3000);For upgrades served through pracht's routing (as an API route), use the Cloudflare adapter. The Vercel adapter cannot serve them either.
When using nodeAdapter() in vite.config.ts, generated entries can import a context factory and tune body limits:
nodeAdapter({
createContextFrom: "/src/server/context.ts",
maxBodySize: 10 * 1024 * 1024,
});The context module must export createContext(args). Node passes { request, req, res }.
Generated by the Vite plugin:
// virtual:pracht/node-server (generated)
import { createNodeRequestHandler } from "@pracht/adapter-node";
// ... resolved app, registry, asset manifest, ISG manifest setup
export const handler = createNodeRequestHandler({
app: resolvedApp,
registry,
staticDir,
isgManifest,
headersManifest,
apiRoutes,
clientEntryUrl,
cssManifest,
});
// Starts the HTTP server only when `node dist/server/server.js` runs directly.Running pracht build for a Node target emits dist/server/server.js, which is
the executable production server entry. pracht preview builds and runs it in
one step (--port/$PORT select the port, --skip-build reuses the existing
build).
| Option | Type | Description |
|---|---|---|
app |
PrachtApp |
The resolved app |
registry |
ModuleRegistry |
Module importers |
createContext |
(args: CloudflareContextArgs) => TContext |
Context with env and executionContext |
isgManifest |
Record<string, ISGManifestEntry> |
Concrete ISG path metadata |
cache |
boolean | { staleWhileRevalidate } |
Serve time-revalidated ISG routes through Workers Caching (see below) |
- Asset serving: uses
env.ASSETS.fetch()binding for static files (Cloudflare handles caching and CDN distribution). Static responses inherit the same default security headers applied to dynamic responses. Prerendered HTML also receives route and shell document headers fromdist/client/_pracht/headers.json. - ISG revalidation: runtime ISG uses the Workers Cache API as the
regenerated-page store, with
env.ASSETSas the build-time fallback. The generated worker readsdist/client/_pracht/isg.json, checks cache freshness from the stored generation timestamp, serves stale HTML immediately for time-based revalidation, and schedules regeneration withexecutionContext.waitUntil().POST /__pracht/revalidateauthenticatesPRACHT_REVALIDATE_TOKENand overwrites the named Cache API entries for routes that opt intowebhookRevalidate(). Successful manifest reads are cached for the isolate lifetime; transient asset or JSON failures are evicted so the next request retries. A missing manifest (404) is cached as empty. - Cache locality: Cloudflare's Cache API is local to the colo handling the request. This keeps ISG dependency-free and fast, but webhook invalidation is not a global purge. Other colos refresh when they receive the webhook or when their cached entry becomes stale and a visitor requests it.
- ISG via Workers Caching: with
cloudflareAdapter({ cache: true }), time-revalidated ISG routes are instead rendered on demand and cached in front of the Worker by Workers Caching for theirrevalidatewindow, with stale pages served instantly while the Worker re-renders in the background — a true edge-tier cache rather than the per-colo Cache API. Webhook-only ISG routes keep the worker-managed path above soPOST /__pracht/revalidatetakes effect immediately; when a route has both a time and a webhook policy, the webhook also purges the edge entry. - Default request context: generated worker entries pass
{ env, executionContext }to pracht so loaders, API routes, and middleware can access bindings without extra wiring. - Build output:
pracht({ adapter: cloudflareAdapter() })makespracht buildemit a Worker bundle indist/server/server.jsplus a thin deploy entry indist/server/worker.jsthat re-exports only the default handler and your Cloudflare entrypoint classes (workerd rejects the build metadata thatserver.jsalso exports for the prerender pass). Pointwrangler.jsonc'smainatdist/server/worker.js— you own that file, which lets you add KV, D1, R2, cron, and any other Cloudflare bindings without losing them on rebuild. - Local preview:
pracht previewrunspracht buildand then delegates towrangler dev --port <port>against the built worker. It requires wrangler (innode_modulesor on PATH) and a wrangler config; it errors with install instructions otherwise. - KV/D1/R2 support: custom context factories and the default build entry both
surface the Cloudflare
envobject. @cloudflare/vite-pluginintegration: the adapter automatically includes@cloudflare/vite-plugin, running the dev server inside workerd so that API routes and loaders have full access to Cloudflare bindings (KV, D1, R2, Queues, etc.) during development.
Workers Caching is a
cache that sits in front of the Worker: Cloudflare stores responses whose
caching headers mark them cacheable and answers repeat requests without
invoking the Worker at all. Pracht maps time-revalidated ISG onto it;
webhook-only ISG routes stay on the worker-managed Cache API path so
POST /__pracht/revalidate takes effect immediately. Enable both sides:
// vite.config.ts
cloudflareAdapter({ cache: true });
// or tune the stale window (seconds; default one year):
cloudflareAdapter({ cache: { staleWhileRevalidate: 86400 } });With the option on:
- Time-revalidated ISG pages are not emitted as static snapshots at build time. The first request after a deploy renders fresh (Workers Caching partitions the cache per Worker version, so deploys always start cold). Webhook-only ISG routes keep their snapshots and the worker-managed path.
- Routes with both a time and a webhook policy are edge-cached, and
POST /__pracht/revalidatepurges their edge entries after regenerating the worker-managed copy. - The worker stamps ISG document responses with
cloudflare-cdn-cache-control: max-age=<revalidate>, stale-while-revalidate=<staleWhileRevalidate>— the edge holds the page for the route'srevalidatewindow, and after the window visitors get the cached page instantly while the Worker re-renders it in the background. The edge directives live incloudflare-cdn-cache-control(highest precedence; Cloudflare consumes and strips it) rather thanCache-Controlbecausemust-revalidate/s-maxageinCache-Controlwould prohibit serving stale (RFC 9111 §4.2.4) and disable stale-while-revalidate. The browser-facing header isCache-Control: public, max-age=0, must-revalidate, matching the Node adapter's ISG behavior. - Responses carry
Cache-Tag: pracht:isg,pracht:route:<id>so they can be purged. Routes that exportmarkdownalso carryVary: Accepton both their HTML and markdown responses so the representations stay separate; routes without that export do not vary onAccept. - A route/shell
headers()export that setsCache-Control(orcloudflare-cdn-cache-control) takes full precedence — pracht adds nothing, so individual routes can opt out or tune their own policy. Pracht also reuses the shared ISG cache-safety policy before stamping edge headers: responses withSet-Cookie,Cache-Control: private/no-store, orVary: Cookie,Vary: Authorization, orVary: *are never stored in the shared edge cache. - Route-state JSON (client navigations) stays
no-storeand always reaches the Worker. - Everything pracht did not deliberately mark cacheable gets
Cache-Control: private, no-cache(unless the response already sets its ownCache-Control). With Workers Caching enabled, Cloudflare would otherwise apply heuristic freshness (~2 hours for 200s) to responses that carry noCache-Controlheader — andCookieis not part of the cache key, so SSR pages (including authenticated ones) and API GET responses would be edge-cached across users.
Workers Caching keys inbound requests by the exact path and query string.
Query parameter order and trailing slashes are significant, so /pricing,
/pricing?ref=a, and /pricing?ref=b populate independent entries with
independent revalidation cycles. This differs from Pracht's Node and
worker-managed Cloudflare ISG caches, which key generated pages by pathname.
It also means arbitrary public query values can create unbounded edge entries
and force cold renders.
Pracht cannot replace the cache key from inside the cached entrypoint: on a
hit, Workers Caching answers before that Worker runs, and custom cf.cacheKey
values are only honored for same-account calls from another entrypoint. Before
enabling Workers Caching, keep ISG query shapes bounded and canonical:
- Redirect or reject unsupported query parameters and enforce one trailing- slash form in an uncached gateway before it calls the cached entrypoint.
- If query parameters do not affect the page, have that gateway call a cached
entrypoint with a pathname-only
cf.cacheKey. This adds a gateway invocation but collapses tracking and attacker-chosen values onto one cache entry. - If a route genuinely renders different content for an unbounded query space,
opt it out with a route
Cache-Control: private, no-storeheader or do not enable Workers Caching for that deployment.
Cloudflare compares request-header values named by Vary verbatim. Pracht
therefore adds Vary: Accept only to routes that actually export markdown,
but those routes can still accumulate variants for semantically equivalent
browser and agent Accept strings. For high-traffic markdown-capable routes,
normalize Accept to a small HTML/markdown set in the same uncached-gateway
pattern. Purges by Pracht's cache tags or path prefixes invalidate all variants
of the matching URL together.
Because cache hits skip the Worker entirely, middleware does not run for cached ISG pages. That matches the previous behavior (static snapshots were served before the framework, too) — keep per-visitor logic on SSR routes.
Purge cached pages from loaders, API routes, or webhook handlers with
purgeCache — this is webhook-based ISG revalidation:
// src/api/revalidate.ts
import { purgeCache, routeCacheTag } from "@pracht/adapter-cloudflare/cache";
export async function POST() {
await purgeCache({ tags: [routeCacheTag("pricing")] });
// also: purgeCache({ pathPrefixes: ["/blog/"] }) or purgeCache({ purgeEverything: true })
return Response.json({ revalidated: true });
}Protect purge webhooks with a shared secret so strangers cannot flush the
cache — see examples/cloudflare/src/api/revalidate.ts for a version that
checks an x-revalidate-secret header against a Worker secret.
Purges are scoped to the Worker that owns the cache — no zone-level purge
touches it, and purgeCache cannot touch other Workers.
Wrangler requires named exports from the worker entry for Workflows, Durable
Objects, Queues, and other Cloudflare primitives. Use the
workerExportsFrom option to point the adapter at a dedicated module that
re-exports them:
cloudflareAdapter({
workerExportsFrom: "/src/cloudflare.ts",
});// src/cloudflare.ts
export { Counter } from "./workers/counter.ts";
export { MyWorkflow } from "./workers/my-workflow.ts";This generates export * from "/src/cloudflare.ts" in the built worker entry,
which is what Wrangler needs to discover and register the classes. Keep the
module focused on Cloudflare primitives so the generated worker entry stays
explicit. Pair this with the corresponding wrangler.jsonc bindings:
{
"durable_objects": {
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
},
}Queues consumers, Cron Triggers, and Email Routing deliver events to handlers
on the worker's default export (queue, scheduled, email, ...), which
the generated entry normally reserves for pracht's fetch. Point
workerHandlersFrom at a module whose named exports should ride along:
cloudflareAdapter({
workerHandlersFrom: "/src/worker-handlers.ts",
});// src/worker-handlers.ts
export async function queue(batch, env, ctx) {
for (const message of batch.messages) await processJob(message, env);
}
export async function scheduled(event, env, ctx) {
await runCronSweep(env, ctx);
}The generated entry becomes
export default { ...handlers, fetch } — every named export of the module is
merged in, but fetch always stays pracht's handler; export request handling
belongs in API routes or middleware instead.
A WebSocket upgrade is request handling, so it belongs in an API route —
not in workerHandlersFrom. Pracht recognises protocol-switch responses
(101 Switching Protocols, or any response carrying a webSocket handle) and
returns them to the runtime untouched: no security headers, no cache headers,
no reconstruction. That last part is the whole trick — copying the response
would drop the webSocket property, since it is a Cloudflare extension to
ResponseInit rather than part of the fetch standard.
The socket itself has to be owned by something that outlives the request, which
on Cloudflare means a Durable Object. Export it via
workerExportsFrom
and have the API route forward the upgrade to it:
// src/api/ws.ts
import type { BaseRouteArgs } from "@pracht/core";
export async function GET({ context, request, url }: BaseRouteArgs) {
if (request.headers.get("upgrade") !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}
const { CHAT_ROOM } = context.env as { CHAT_ROOM: DurableObjectNamespace };
const room = url.searchParams.get("room") ?? "lobby";
// The 101 comes straight back out — pracht passes it through unchanged.
return CHAT_ROOM.get(CHAT_ROOM.idFromName(room)).fetch(request);
}// src/workers/chat-room.ts
import { DurableObject } from "cloudflare:workers";
export class ChatRoom extends DurableObject {
override async fetch(request: Request): Promise<Response> {
const { 0: client, 1: server } = new WebSocketPair();
// Hibernation-aware accept: an idle room is evicted from memory without
// dropping its sockets.
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
override webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
for (const peer of this.ctx.getWebSockets()) peer.send(String(message));
}
}Bind and register the class in wrangler.jsonc as with any Durable Object:
{
"durable_objects": {
"bindings": [{ "name": "CHAT_ROOM", "class_name": "ChatRoom" }],
},
"migrations": [{ "tag": "v1", "new_classes": ["ChatRoom"] }],
}Upgrades work in pracht dev the same way they do in production — the
Cloudflare adapter owns the dev server, so requests are served by workerd
rather than by pracht's Node dev handler.
examples/cloudflare has the full version of both files.
Cross-origin upgrades are blocked by default. Browsers do not apply CORS to WebSocket: without a check, any page on the web could open a socket to your app and the user's cookies would ride along (cross-site WebSocket hijacking). Pracht therefore applies its
api.requireSameOrigincheck to upgrade requests as well as to unsafe methods. Requests with no browser provenance headers at all (CLIs, server-to-server) still pass, as with mutations.Two things pracht cannot do for you: authenticate the connection (do it in API middleware or in the handler before forwarding — the handshake is a normal request and carries cookies), and authorize each message once the socket is open, which is entirely the Durable Object's business.
The 30-second signal an API route receives covers the handshake, not the
connection: forwarding to a Durable Object returns immediately, and the socket's
lifetime is then the object's to manage. Do not hold the socket in the API
route itself.
The adapter handles everything — just declare bindings in wrangler.jsonc:
{
"main": "dist/server/worker.js",
"kv_namespaces": [{ "binding": "MY_KV", "id": "..." }],
"d1_databases": [{ "binding": "DB", "database_name": "my-db", "database_id": "..." }],
}The main field stays pointed at dist/server/worker.js for production
deploys. During dev, the adapter automatically overrides the entry to
pracht's virtual server module via @cloudflare/vite-plugin — no extra
files needed.
Bindings are available via context.env in loaders, middleware, and API routes:
// src/api/items.ts
import type { BaseRouteArgs } from "@pracht/core";
export async function GET({ context }: BaseRouteArgs) {
const value = await context.env.MY_KV.get("key");
return Response.json({ value });
}When using cloudflareAdapter() in vite.config.ts, generated entries can import a context factory:
cloudflareAdapter({
createContextFrom: "/src/server/context.ts",
workerExportsFrom: "/src/cloudflare.ts",
});The context module must export createContext(args). Cloudflare passes { request, env, executionContext }.
// virtual:pracht/server (generated in cloudflare mode)
import { handlePrachtRequest, resolveApp, resolveApiRoutes } from "@pracht/core";
import { app } from "./src/routes.ts";
const resolvedApp = resolveApp(app);
const apiRoutes = resolveApiRoutes(Object.keys(apiModules), "/src/api");
export default {
async fetch(request, env, executionContext) {
const assetResponse = await env.ASSETS.fetch(request);
if (assetResponse.status !== 404) return assetResponse;
return handlePrachtRequest({
app: resolvedApp,
registry,
request,
context: { env, executionContext },
apiRoutes,
});
},
};| Option | Type | Description |
|---|---|---|
app |
PrachtApp |
The resolved app |
registry |
ModuleRegistry |
Module importers |
createContext |
(args: VercelContextArgs) => TContext |
Context with the incoming edge-function context |
- Edge runtime handler: generated server entries export a default
fetch-style handler that Vercel bundles as an Edge Function. - Build Output API v3:
pracht({ adapter: vercelAdapter() })makespracht buildemit.vercel/output/config.json,.vercel/output/static/,.vercel/output/functions/render.func/, and route-named prerender functions for ISG paths. - Local preview: there is no faithful local Vercel production runtime, so
pracht previewdoes not emulate one — it points atvercel build/vercel devinstead. - Clean URL routing: prerendered SSG pages are copied into
.vercel/output/staticand exposed throughconfig.jsonrewrites so/aboutresolves to/about/index.html. - Route-state bypass: Vercel build output adds rules for both
x-pracht-route-state-request: 1and?_data=1, so route-state requests go to the edge function before any static SSG rewrite can serve cached HTML. - Native ISR: ISG routes are emitted as Build Output API prerender functions
with
.prerender-config.jsonfiles. Time policies become Vercelexpirationvalues, build-time HTML becomes the prerender fallback, andPRACHT_REVALIDATE_TOKENis used as thebypassTokenwhen present at build time. If the env var is absent during build, Pracht writes a random bypass token and the runtime webhook endpoint still fails closed until the env var is configured. The token must be set at build time: thebypassTokenis baked into the build's*.prerender-config.json, so setting the env var only at runtime authenticates the webhook but cannot bypass Vercel's prerender cache — such paths are reported asfailed(detected via thex-vercel-cacheresponse header) until you rebuild withPRACHT_REVALIDATE_TOKENset. - Function-name safety: the build fails with a descriptive error when an ISG
route would use the same
.funcdirectory as the main edge function (for example,/renderwith the defaultfunctionName: "render"). Rename the route or set a non-conflictingfunctionNameinvercelAdapter(). - Dynamic fallback: SSR and API routes are routed to the generated edge function. ISG document requests are handled by route-named prerender functions, while route-state requests still bypass static/prerender output and reach the edge function.
- Static security headers: the generated
config.jsonincludes aheaderssection that applies the same baseline security headers to all responses, including static assets served by Vercel's CDN. Static prerendered routes also get route and shell document headers from the prerender header manifest. SSG/ISG prerendering rejects dangerous document headers such asSet-Cookie,Authorization,Proxy-Authenticate,WWW-Authenticate, and secret-shaped customx-*headers before they can enter that manifest.
When using vercelAdapter() in vite.config.ts, generated entries can import a context factory:
vercelAdapter({
createContextFrom: "/src/server/context.ts",
functionName: "render",
regions: ["iad1"],
});The context module must export createContext(args). Vercel passes { request, context }.
// virtual:pracht/server (generated in vercel mode)
import { resolveApp, resolveApiRoutes } from "@pracht/core/server";
import { createVercelEdgeHandler } from "@pracht/adapter-vercel";
import { app } from "./src/routes.ts";
const resolvedApp = resolveApp(app);
const apiRoutes = resolveApiRoutes(Object.keys(apiModules), "/src/api");
export const vercelFunctionName = "render";
export default async function handle(request, context) {
const handler = createVercelEdgeHandler({
app: resolvedApp,
registry,
apiRoutes,
clientEntryUrl: clientEntryUrl ?? undefined,
cssManifest,
});
return handler(request, context);
}Routes opt into webhook revalidation with webhookRevalidate() or by combining
it with timeRevalidate():
import { timeRevalidate, webhookRevalidate } from "@pracht/core";
route("/pricing", () => import("./routes/pricing.tsx"), {
render: "isg",
revalidate: [timeRevalidate(3600), webhookRevalidate()],
});All built-in adapters expose the same endpoint:
curl -X POST https://example.com/__pracht/revalidate \
-H "Authorization: Bearer $PRACHT_REVALIDATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["/pricing"]}'The body must include paths as an array of at most 64 concrete URL paths;
larger batches are rejected with 400. The endpoint returns JSON with
revalidated, skipped, and failed path arrays. A path is skipped when it is
not an ISG route, is not in the prerender manifest, or does not opt into
webhookRevalidate(). A path lands in failed when regeneration did not
produce cacheable 200 HTML (loader error, malformed manifest metadata,
Set-Cookie, Cache-Control: private/no-store, cache write failure); the
previously generated copy stays live, and the batch continues instead of
aborting with a 500.
Set PRACHT_REVALIDATE_TOKEN in the deployment environment. Auth uses a
constant-time comparison and fails closed with 401 when the token is missing
or incorrect. Webhook providers that cannot send bearer auth can send the same
secret in x-pracht-revalidate-token.
Regeneration never replays the webhook request's cookies, authorization
headers, locale, or other user-specific headers. Adapters synthesize a clean
GET document request for the target path.
Concurrent regenerations of the same path are single-flighted: a stampede of stale requests (or repeated webhook posts) share one in-flight render per process/isolate instead of racing N parallel regenerations.
Single-flight callers join the render that is already running. A webhook that
arrives during a stale-request regeneration can therefore report the path as
revalidated even when that render started before the content change that
triggered the webhook. Send a later webhook when strict post-change freshness
is required.
Dynamic ISG paths that getStaticPaths() did not enumerate at build time are
not in the prerender manifest. Regular requests for such paths still work —
they fall through to the server render on every request, without a cached
copy. Webhook posts naming them are reported as skipped on Node and
Cloudflare (nothing cached to refresh). Vercel matches route patterns rather
than the manifest, so such paths are accepted, but only build-time enumerated
paths have prerender functions — new concrete paths are served per-request by
the edge function.
A custom adapter exports a factory function that returns a PrachtAdapter object:
import type { PrachtAdapter } from "@pracht/vite-plugin";
import { myPlatformVitePlugin } from "my-platform-vite-plugin";
export function myAdapter(options?: MyOptions): PrachtAdapter {
return {
id: "my-platform",
serverImports:
'import { handlePrachtRequest, resolveApp, resolveApiRoutes } from "@pracht/core";',
createServerEntryModule() {
// Return JavaScript source code that will be appended to the
// generated virtual:pracht/server module.
return `
export default async function handle(request) {
return handlePrachtRequest({
app: resolvedApp,
registry,
request,
apiRoutes,
clientEntryUrl: clientEntryUrl ?? undefined,
cssManifest,
jsManifest,
});
}
`;
},
// Optional: contribute extra Vite plugins (e.g. a platform-specific runtime).
// This hook is synchronous so pracht can return its complete Vite plugin
// array synchronously from vite.config.ts.
vitePlugins() {
return myPlatformVitePlugin({ entry: "virtual:pracht/server" });
},
// Optional: set to true when the adapter's Vite plugin runs the dev server
// itself (pracht will skip installing its own SSR middleware).
ownsDevServer: true,
// Optional: set to true when targeting an edge runtime that cannot resolve
// dependencies from node_modules at runtime. Forces Vite to bundle all
// dependencies into the SSR output (ssr.noExternal = true).
edge: true,
};
}The generated server entry module has access to resolvedApp, registry,
apiRoutes, clientEntryUrl, cssManifest, and jsManifest -- your
createServerEntryModule() code can reference these directly.
At the runtime level, an adapter also typically needs to:
- Accept a platform request and convert it to a Web
Requestobject - Check for static assets -- serve files from
dist/client/with appropriate headers (content-type, cache-control with immutable for hashed assets). Skip asset serving when the request hasAccept: text/markdownso routes that export amarkdownsource can respond from the framework. - Check for prerendered pages -- SSG and ISG routes have HTML files on disk. For ISG, implement staleness checking.
- Delegate dynamic requests to
handlePrachtRequest()frompracht. This ordering is what letsdefineApp({ notFound })stay safe: the not-found page only renders once matching and asset serving have missed, so it can never shadow a real file. If the platform is configured to answer misses itself (e.g. Cloudflare'sassets.not_found_handling), that answer wins and the app's not-found page never runs. - Convert the Web
Responseback to the platform's response format - Provide a context factory -- create app-level context from platform-specific inputs (env bindings, headers, etc.)
The context factory lets adapters inject platform-specific values into loaders, middleware, and API routes:
// Node: inject database pool
createContext: ({ request }) => ({
db: pool,
ip: request.headers.get("x-forwarded-for"),
});
// Cloudflare: inject env bindings
createContext: ({ request, env, executionContext }) => ({
db: env.DB, // D1 binding
kv: env.CACHE, // KV binding
waitUntil: executionContext.waitUntil.bind(executionContext),
});This context is available in every loader, middleware, and API route as args.context.