[ HTTP & responses ]
@sdxc/get-client-ip
Read the client IP from a Cloudflare Workers request
- Installs with
- @sdxc/ip@sdxc/result
- Used by
- uptime, auth-saas, blog, books
- Source
- packages/get-client-ip
Read the client IP from a Cloudflare Workers request, parsed into an address.
Cloudflare attaches
CF-Connecting-IP
to every request it routes, carrying the address the connection came from. This package reads
that header and parses it into an IP from @sdxc/ip,
so only a valid address ever becomes a rate limit key or a stored value.
Installation
npm add @sdxc/get-client-ip
@sdxc/ip and remix, for the middleware, are installed
with it.
Usage
Read the caller's address
import { getClientIP } from "@sdxc/get-client-ip";
export function GET(request: Request) {
let ip = getClientIP(request); // IP | null
return Response.json({ ip }); // {"ip":"203.0.113.42"}
}
An IP serializes as its canonical text, so it goes straight into JSON.
Fall back when there is no address
A request Cloudflare did not route (a local dev server, a test) has no header, and a header that
is not one address parses to nothing; both answer null, so one fallback covers them:
let ip = getClientIP(request)?.toString() ?? "unknown";
Publish it on every request
The middleware parses the header once per request and publishes it as ctx.ip:
import getClientIP from "@sdxc/get-client-ip/middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [getClientIP()] });
router.get("/", (ctx) => {
return Response.json({ ip: ctx.ip?.toString() ?? null });
});
API
getClientIP(request: Request): IP | null
Parses the request's CF-Connecting-IP header into an IP, or answers null when the header
is absent or is not a single address.
@sdxc/get-client-ip/middleware
getClientIP(): Middleware (default export)
A router middleware that sets ctx.ip to the parsed address. Importing the module types
ctx.ip as IP | null.
ClientIP
The context key the middleware writes, for code that reads the value with ctx.get(ClientIP).
A context the middleware never ran on reads null.
Pattern: Rate Limiting Per Client Network
An IPv6 client is usually assigned a whole /64 and can send each request from a different
address in it. Keying IPv6 on its /64 and IPv4 on the full address gives one budget per client:
import { getClientIP } from "@sdxc/get-client-ip";
import { ok, tooManyRequests } from "@sdxc/response";
let LIMIT = 100;
let WINDOW_SECONDS = 60;
export default {
async fetch(request: Request, env: { KV: KVNamespace }) {
let client = getClientIP(request)?.network({ v4: 32, v6: 64 }).toString() ?? "unknown";
let key = `rate-limit:${client}`;
let spent = Number((await env.KV.get(key)) ?? "0");
if (spent >= LIMIT) return tooManyRequests({ error: "Rate limit exceeded" });
await env.KV.put(key, String(spent + 1), { expirationTtl: WINDOW_SECONDS });
return ok({ status: "up" });
},
};
Requests without a usable address share the unknown bucket, which keeps their budget finite.
The status helpers come from @sdxc/response.
Pattern: Restricting Routes To A Network
A guard middleware reads the address through the ClientIP key and answers before the handler
when it falls outside an allowed range:
import getClientIP, { ClientIP } from "@sdxc/get-client-ip/middleware";
import { IP } from "@sdxc/ip";
import { isSuccess } from "@sdxc/result";
import type { Middleware } from "remix/router";
import { createRouter } from "remix/router";
let OFFICE = IP.Range.parse("203.0.113.0/24");
function officeOnly(): Middleware {
return (ctx, next) => {
let ip = ctx.get(ClientIP);
if (ip === null || !isSuccess(OFFICE) || !OFFICE.data.contains(ip)) {
return new Response("Forbidden", { status: 403 });
}
return next();
};
}
let router = createRouter({ middleware: [getClientIP(), officeOnly()] });
Pattern: Attaching The Address To A Log Line
The address goes onto the request's wide event from
@sdxc/logger as one field:
import type { Middleware } from "remix/router";
export function logClient(): Middleware {
return (ctx, next) => {
ctx.log.set({ client: { ip: ctx.ip?.toString() ?? null } });
return next();
};
}
Mount it after log(logger) and getClientIP(), so both ctx.log and ctx.ip are set when it
runs. An IP address is personal data in many jurisdictions, so decide its retention before the
log line outlives the request.
Pattern: Pairing It With Cloudflare's Geolocation
A Worker request also carries a cf object with the location Cloudflare resolved for the same
connection:
import { getClientIP } from "@sdxc/get-client-ip";
export function GET(request: Request) {
return Response.json({
ip: getClientIP(request),
country: request.cf?.country,
city: request.cf?.city,
});
}
request.cf is typed by
@cloudflare/workers-types.