@noverax/courier-astro serves the proxy that createProxyCaller and createProxyQueryCaller call: one route answers every endpoint, reads over GET and writes over POST, and streams server-sent events.
In one command
npx @noverax/create-courierIn an Astro site it adds what this page sets up by hand — the route rendered on demand, the middleware that registers your APIs, and Studio's integration — with a typed example API, its mock, and a page at /courier. See Create Courier.
Install
npm install @noverax/courier-astro @noverax/courier astro
npx astro add nodeThe proxy answers each call as it arrives, so its route renders on demand, and that needs a server adapter. @astrojs/node is shown; any adapter works.
Usage
// src/middleware.ts
import { defineMiddleware } from "astro:middleware"
import { registerSources } from "@noverax/courier"
import { sources } from "./api"
registerSources(sources)
// Astro requires its middleware module to export `onRequest`.
export const onRequest = defineMiddleware((_context, next) => next())// src/pages/api/request.ts
import { courierHandler } from "@noverax/courier-astro"
// Rendered on demand: a prerendered route answers every call with the
// same file built once at build time.
export const prerender = false
const handler = courierHandler()
export const GET = handler
export const POST = handlercourierHandler() is zero-arg — sources are read at request time from the core global registry populated by registerSources() at module load.
export const prerender = false is what keeps the route on demand in Astro's default static output, where every other page stays prerendered. Without it the build renders the route once, and the deployed site answers every call with that file: a POST gets 200 and the same error body, with nothing to show it failed. With output: "server" routes are on demand already and the line changes nothing.
Uploads
Astro checks the Origin of form submissions — multipart/form-data, which uploads use — against the origin it believes it serves, and answers a mismatch with a plain-text 403 "Cross-site POST form submissions are forbidden" before the proxy sees the call. A caller that sends no Origin at all, such as a server-side script, is refused the same way. When a same-origin upload is refused in production, Astro could not tell its own address; name it:
// astro.config.mjs
import { defineConfig } from "astro/config"
import node from "@astrojs/node"
export default defineConfig({
adapter: node({ mode: "standalone" }),
security: { allowedDomains: [{ protocol: "https", hostname: "app.example.com" }] },
})The proxy reads an upload up to its maxUploadBytes — 10 MB unless you pass another, as in courierHandler({ maxUploadBytes: 50 * 1024 * 1024 }) — and a JSON call up to maxBodyBytes, 1 MB.
The client
Pair with createProxyCaller on the client:
import { createProxyCaller } from "@noverax/courier"
import type { api } from "./api"
// The first argument is the key this source has in your `sources` map —
// it is what the proxy addresses, and it travels as `?_s=<name>`.
const caller = createProxyCaller<typeof api>("api", {
proxyPath: { http: "/api/request" },
})
const users = await caller.users.getAll()The user's session
An API that keeps a session gets the user's session with every call a page's frontmatter, an endpoint, an action or a step it awaits makes, once the request is served with it at hand:
import { requestContext } from "@noverax/courier-astro/request-context"
export const onRequest = requestContext()Put it in a sequence when the application has middleware of its own. The session cookies the API sets reach the browser through Astro's cookies, as a page's own do. The request is kept in AsyncLocalStorage, which Node provides, and Cloudflare with the nodejs_compat flag; the main entry imports none of it. A page built ahead of time has no request, and its calls carry no one's session.
Astro reads the host a request was sent to only from a domain it is told to trust, and otherwise takes every request for http://localhost, where its own origin check refuses the application's forms and the session cannot tell your pages from another site's. Name your domains in security.allowedDomains:
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
security: { allowedDomains: [{ hostname: "shop.example.com", protocol: "https" }] },
})Astro 5 is checked with @astrojs/node. See User Sessions.
Cancelled calls
When the browser cancels a call — an aborted upload, a query it no longer needs — the proxy stops its own upstream request, following the request's signal. @astrojs/node fires that signal when the client leaves. The astro dev server does not, and exposes no connection to watch instead, so during development a cancelled call still runs to completion upstream.