Request
- Category: Core
request is the incoming HTTP request, parsed once and handed to your
server-side code. A loader, a .ctx, and a
.middleware all receive it. Read headers, cookies, the URL, the
method, or the client IP off it; write the response with the separate
set helper that arrives alongside it.
export const meQuery = root.lets
.query()
.loader(async ({ request }) => {
const token = request.headers['authorization'] // header keys are lowercased
const session = request.cookies['session'] // cookies already parsed
const me = await findUser(token, session)
return { me }
})
.query()request is a Request0 — a thin, parsed wrapper over the native Request.
Field access is cheap; the heavier parsing (headers, cookies, client IP) is
deferred until first read and then cached.
Where you get it
request is injected into the three server-side callbacks:
.loader(({ request }) => { /* ... */ }) // a query/mutation/page loader
.ctx(({ request }) => { /* ... */ }) // .ctx
.middleware(({ request, next }) => next()) // .middlewareOutside those, reach it from anywhere on the server with getRequest():
import { getRequest, getRequestOrUndefined } from '@point0/core'
const request = getRequest() // throws if no request is in scope
const request = getRequestOrUndefined() // undefined instead of throwingUse getRequestOrUndefined() in helpers that may run off-request — an analytics
call, an error reporter — so they don't throw when there's no request:
const request = getRequestOrUndefined()
mixpanel?.track(event, { ip: request?.from.ip ?? undefined })request is server-only — there is no request object in the browser. The
request lives in server-only async storage, so the two accessors behave
differently on the client:
getRequest()is strict: on the client it throwsCannot access serverOnlyStorage item "..." from client.getRequestOrUndefined()is exactly what you reach for off the server — on the client it returnsundefinedinstead of throwing, so isomorphic helpers stay safe to call from a component or hook.
Reading headers
request.headers is a plain object with all keys lowercased:
request.headers['authorization'] // => 'Bearer ...' | undefined
request.headers['content-type'] // => 'application/json' | undefined
request.headers['x-anything'] // any key works, always lowercaseIt's a snapshot (Record<string, string | undefined>), not a Headers instance
— no .get(), no multi-value semantics, a missing key is undefined. When a
library wants a real Headers, hand it request.original.headers instead —
this is the usual shape for auth:
const me = await authServer.api.getSession({
headers: request.original.headers,
})request.headers is built by iterating the native Headers with
Headers.forEach, which already collapses a duplicated header into a single
value per key (comma-joined, except set-cookie, which the runtime keeps
separate). So a key maps to one combined string, never an array. When you need
the per-value list — multiple set-cookie, say — reach for
request.original.headers and its Headers API instead.
Reading cookies
request.cookies is the incoming cookies, already parsed into an object:
request.cookies['session'] // => 'abc123' | undefinedValues are URL-decoded and surrounding quotes are stripped; no cookie header
gives an empty object {}. This view is read-only — it's what the client
sent. To set or delete a cookie on the response, use the set helper, not
request.cookies:
.loader(({ request, set }) => {
const current = request.cookies['session'] // read incoming
set.cookies('session', newToken) // write outgoing
})See Response for set.cookies, and CookieStore for
the reactive store that layers your outgoing changes over request.cookies.
Parsing details:
- Quoted values — a leading
"strips the surrounding quotes ("abc"reads back asabc). - Percent-encoding —
%XXsequences in the value are URL-decoded; if a value is malformed and decoding throws, the unquoted value is kept as-is. The cookie name is decoded too, and if that throws the raw name and raw value are stored untouched. - Duplicate names — last one wins; the cookies are parsed into a plain
object, so a later
name=overwrites an earlier one. __Host-/__Secure-prefixes — no special handling; they're ordinary names parsed like any other cookie.
The URL and route
There is no request.url. For the parsed URL use request.location, for
the raw string use request.original.url:
request.location.pathname // => '/ideas/42'
request.location.search // => { tab: 'posts' } (parsed query object)
request.location.searchString // => '?tab=posts' (raw, or '')
request.location.hash // => '#section' (raw, or '')
request.location.href // => full absolute href (absolute inputs only)
request.original.url // => the raw URL stringrequest.location is a route0 location. Its pathname is the raw
URL pathname — trailing slash preserved as-is (a request to /ideas/ reads back
'/ideas/', not '/ideas'); route matching normalizes internally, but the
value you read off request.location.pathname is not normalized. search is
the parsed query object.
Route params are not on request. They arrive as a separate, typed and
validated params option prop when you declare a
.params(schema) — same for search, body, and the validated
headers/cookies subsets:
.loader(({ request, params }) => {
params // typed & validated — only present if .params(schema) was declared
})There is no raw, untyped route-params view on request. request.location is
an unrouted location, so its params is undefined. The matched params do live
on the engine's classified variant — request.variant.location.params for an
endpoint — but that is internal plumbing, not stable read-side API. Read
params through the validated params option prop instead.
So headers/cookies exist twice with different meanings: on request
they are the raw, full set (string | undefined); as an option prop they
are the validated subset you declared a schema for. See
Validation.
The method
Always uppercase:
request.method // => 'GET' | 'POST' | 'PUT' | ...Typed as WideRequestMethod — the standard methods plus an open string, so
custom methods type-check.
Where the request came from: request.from
request.from carries the request's origin — IP, user agent, referrer, scope.
Each member is parsed lazily on first read and cached:
request.from.ip // => '203.0.113.7' | null (unspoofable Bun requestIP, safe for security)
request.from.ips // => ['203.0.113.7', '70.41.3.18'] (all candidates, incl. spoofable)
request.from.userAgent // => 'Mozilla/5.0 ...' | null
request.from.location // => the referrer as a parsed location | null
request.from.scope // => an internal point0 scope header | null
request.from.server // => true if this is a server-to-server requestA structured log from from and location:
console.log({
request: {
ip: request.from.ip,
userAgent: request.from.userAgent,
pathname: request.location.pathname,
method: request.method,
variant: request.variant.type,
},
})IP resolution
from.ip is always Bun's requestIP — the real socket peer address, which
can't be spoofed — or null when no Bun server is wired in. It never falls back
to headers, so it's safe for security decisions.
from.ips is the full list of every candidate, de-duplicated, in this order:
- Bun's
requestIP(...)— the unspoofable peer address (when a Bun server is wired in); it leads the list. x-forwarded-for— split on,, each entry trimmed, in order.x-real-ip.cf-connecting-ip(Cloudflare).
Everything after Bun's requestIP comes from headers the client can spoof —
treat from.ips as hints, not proof.
// behind a Bun server, with x-forwarded-for: '1.1.1.1'
request.from.ip // => the real peer address — the forwarded header can't override it
request.from.ips // => [<peer address>, '1.1.1.1']
// no Bun server wired in (e.g. a synthetic request)
request.from.ip // => null — header values never become `ip`
request.from.ips // => ['1.1.1.1'] (header candidates are still listed)During SSR the loaders Point0 prefetches run on internal server-to-server
requests, but request.from still reports the original visitor — Point0
reads it from the first request in the chain. So from.ip in a loader is the
real client IP, not the server's loopback. (from.server is the exception: it's
true on those internal hops.)
Referrer, scope, server flag
from.locationis thereferer(or nativereferrer) parsed into a location, ornull.request.from.location?.pathnamegives the page the request came from.from.scopeis the scope of the client that sent the request — usuallyroot. With several clients it's whichever client the request came from. point0 carries it in the internalX-Point0-From-Scopeheader on its own server-to-server fetch;nullwhen no client scope is attached.from.serveristruefor an internal request point0 made on the server during SSR (see Server-to-server chains).
from.location parses the referrer the same way request.location parses the
URL. A relative referer (e.g. /dashboard) still gives you a location —
pathname, search, hash resolve, but href/origin/host come back
undefined (no absolute base to fill them). A malformed referer that
can't be parsed at all surfaces as an error from the getter rather than null,
so this is not the place to validate untrusted input.
Per-request storage: state vs cache
Two scratch maps hang off the request. They differ in lifetime.
request.state is per request instance — set something in middleware, read
it in a later loader of the same request:
.middleware(({ request, next }) => {
request.state.startedAt = Date.now()
return next()
})
.loader(({ request }) => {
const elapsed = Date.now() - (request.state.startedAt as number)
})request.cache is shared along the whole request chain. During SSR, point0
spawns extra server requests to prefetch page data; they travel the same path,
and cache is the same object across all of them. Use it to memoize work once
per render, like the current user:
// runs once per request, even across SSR's chained sub-requests
export const getMe = async ({ request }: { request?: Request0 } = {}) => {
request ??= getRequest()
if (request.cache.me !== undefined) return request.cache.me // already resolved
const me = await authServer.api.getSession({
headers: request.original.headers,
})
request.cache.me = me
return me
}Both state and cache are typed { [key: string]: unknown }. Add typed keys
by augmenting the module:
declare module '@point0/core/request0' {
interface RequestCache {
me?: Me | null
}
interface RequestState {
startedAt?: number
}
}For SSR prefetch chains, reach for cache (chain-shared), not state
(per-instance).
The native request: request.original
request.original is the underlying Fetch API Request. It's the escape hatch
for anything that wants the raw request — auth libraries, a handler you delegate
to:
// hand the whole raw request to an auth handler
.middleware('/api/auth/*', async ({ request }) => authServer.handler(request.original))Reach for request.original when you need native Headers, the request body
stream, or formData(). Point0 reads the body off request.original for you
and exposes it parsed as the loader's body option (when a
.body schema is declared) — you rarely consume
request.original directly for the body.
request.rawBody exists alongside it, but it's the engine's internal raw/parsed
body cache — advanced/internal, not stable read-side API. Consume the parsed
body option, not rawBody.
Server-to-server chains
When a loader triggers another point0 query on the server during SSR, point0 builds a child request linked to the parent. The child:
- inherits the parent's
cookieheader (so auth carries through); - shares the parent's
request.cache(so per-request memoization holds); - has
request.from.server === true; - links back via
request.prev(the parent) andrequest.first(the root of the chain).
request.prev // => the parent Request0 | undefined
request.first // => the first Request0 in the chain | undefined
request.from.server // => true inside such a chained requestThis is why request.cache (chain-shared) beats request.state (per-instance)
for cross-hop work: each hop gets a fresh state but the same cache.
Reference
Fields
| Field | Type | Notes |
|---|---|---|
original | Request | the native Fetch request — the escape hatch |
headers | Record<string, string | undefined> | lowercased keys; a snapshot, not Headers |
cookies | Record<string, string | undefined> | incoming, parsed, read-only |
location | AnyLocation | parsed URL — pathname, search, hash, href, … |
method | WideRequestMethod | always uppercase |
from | RequestFrom | origin info (see below) |
state | RequestState | per-instance scratch map; augmentable |
cache | RequestCache | chain-shared scratch map; augmentable |
renders | number | SSR render-pass count; 0 for plain endpoints; read-only |
variant | RequestVariant | how point0 classified the request (see below) |
id | string | per-hop request id (each chain hop gets its own) |
prev / first | Request0 | undefined | parent / root in a server-to-server chain |
rawBody | unknown | engine-managed raw/parsed body cache — advanced/internal |
request.from
| Member | Type | Notes |
|---|---|---|
ip | string | null | ips[0] or null; spoofable unless from Bun requestIP |
ips | string[] | all IP candidates, de-duped, trusted-first |
userAgent | string | null | the user-agent header |
location | AnyLocation | null | referrer parsed into a location |
scope | string | null | scope of the client that sent it (usually root) |
server | boolean | true for an internal server-to-server request |
request.variant
request.variant is how the engine classified this request. It starts
{ type: 'unknown' } and the engine sets it as the request is routed, so early
middleware may still see 'unknown'. The discriminant is
'publicdir' | 'endpoint' | 'page' | 'error' | 'unknown':
request.variant.type // => 'page' | 'endpoint' | ...
'point' in request.variant ? request.variant.point?.id : undefined // => 'root:page:home'request.renders
The SSR render-pass counter, backed by cache (so chain-shared). Live during
SSR — a loader prefetched on the first pass reads 1 — and the final total once
the request settles; 0 for a plain endpoint request with no SSR. It's
read-only: assigning to it throws (getter with no setter). The engine also
emits the final total as a dev-only X-Point0-Renders-Count response header.
request.id
A per-hop request id, generated when the request is created. Treat it as opaque:
it identifies a single hop, not the whole chain. Each server-to-server hop gets
its own fresh id, so to correlate across a prev/first chain follow those
links (or read request.first.id), not id alone.
Reading vs writing
request is the read side. To write the response — headers, cookies,
status — use the set helper that arrives next to request in every loader,
.ctx, and middleware:
.loader(({ request, set }) => {
const token = request.headers['authorization'] // read
set.status(201) // write
set.cookies('session', token) // write
})Full response surface is on Response; the reactive cookie store is on CookieStore.
Enjoying Point0?
Star Point0
Start0
YouTube
Discord
Telegram
of the Lord Jesus Christ ☦️
With love for developers
of all backgrounds around the world ❤️