@camada/core
v0.5.2
Published
camada SDK core: BLK snapshot parser/matcher (v3-v5, ordered custom rules), snapshot client, event queue, redaction, trusted-proxy IP resolution
Downloads
1,093
Readme
@camada/core
The shared substrate of the camada backend SDKs: the BLK snapshot parser and matcher (a 1:1
port of the edge collector's blocklist.js, pinned by golden conformance fixtures generated
from the reference implementation), the /snapshot polling client, the batched event queue,
the redaction layer, and trusted-proxy client-IP resolution.
Applications don't install this directly — use @camada/node,
@camada/next, @camada/hono, or one of the Web-fetch
adapters (@camada/sveltekit, @camada/nuxt, @camada/remix, @camada/bun, @camada/deno).
Those depend on it, so npm install of any of them brings it along.
What's inside
parseSnapshot(bin, meta)/Matcher— sub-microsecond checks (IPv4/IPv6 ranges, ASN, country, TLS fingerprint, path, user agent, request headers). At the SDK position onlyip,path,uaand the headers are normally known, so ASN/country/TLS entries and conditions simply never match there: the SDK enforces what it can see and fails open on everything else. ~5 MB of typed arrays resident per loaded snapshot.- Custom rules (snapshot v5) — the tenant's ordered rule list travels in the container
(sections 14/15 +
meta.rules) and is compiled into predicates at parse time.match()walks the rules first and the order is the precedence: first match wins, and only if nothing matched does it fall through to the allow → block → challenge sides. The four actions:skippasses (allowed: true),block403s,challengeserves the proof-of-work page,warnpasses and stamps the event. A rule outcome carriesaction,rule(the id) andreason: 'rule'; at most one ofallowed/block/challenge/warnis ever true. A condition the request cannot answer (noua, no header getter, noasn/country/tlsxoff Cloudflare) is false for every operator, negatives included — the rule then does not fire. - Path matching is canonical — pass the raw request path (still percent-encoded, query
allowed). Both it and every published path value are canonicalised (
src/snapshot/path.ts, contracts §D3): printable%XXdecoded except%2F, ASCII lower-cased,;paramscut,//and a trailing/dropped,./..resolved. So/%62locked-path,/BLOCKED-PATH,/blocked-path/and/x/../blocked-pathall hit a/blocked-pathblock, a prefix/locked/covers/lockeditself, and path regexes are case-insensitive. A block, challenge or warn fires on any spelling; an allow entry or askipneeds both canonical spellings, so/public/../adminnever borrows/public/'s exemption. - Header conditions — a rule may read one request header (
is,contains,matches). Passheader: (name) => string | nullon theMatchInput; it is always called with a lower-cased name, so the name a rule was written with never has to match the wire spelling. Headers are the request plane's alone: the analyst cannot judge one and treats such a rule as not matching, so a header rule is enforced by v5 SDKs only. SnapshotClient— pollsGET /snapshot(Bearer snapshot token) every 30 s off the request path withIf-None-Match; 304 keeps the snapshot and still refreshes tenant config; 204 means "nothing published" (enforce nothing); any error keeps the previous snapshot and paces the next poll (max(retry-after, 5 s), capped at the refresh interval).snapshotVersionis 3 | 4 | 5 (default 5, sent asx-camada-snapshot); a tenant whose analyst has not published that container is answered with the next one down.timermode for long-lived Node processes,lazymode for serverless/edge (ensureFreshper request; cold start fails open). With thesdkoption every poll carriesx-camada-sdk: <package>/<version>so the analyst can show versions and nudge upgrades.EventQueue— batched fire-and-forget shipping toPOST /e(≤1000 per POST), bounded queue with drop-oldest, interval + size-triggered flush, opt-in Node exit drain. The samesdkoption stampsx-camada-sdkon every batch. Nothing in the queue may ever throw into the request path.buildWireEvent— the collector-compatible wire shape, including the pinnedHDRSheader-bitmask order and the wire header order (hord) only an in-app SDK can see.- Redaction (non-configurable-off) —
Authorization/Cookievalues never leave (scheme and count only; a schemelessAuthorizationvalue ships nothing at all), credential-looking query parts become~r(in the request's own query and in theReferer's query and fragment,scrubUrl; paths are not rewritten, in either). Names and values are percent-decoded before the test, and a value that decodes to a nested query (?,&or=) is dropped whole when any of its segments is a credential (next=/reset?token=…, an encodedredirect_uricarrying acode); other values, attack payloads included, stay readable for the analysis. User identifiers are HMAC-hashed in-process (hashUserId).bodyShapeandREDACT_ALLOWLISTare staging for app-context body capture (a later phase); no SDK captures request bodies today. This package is open source so these claims are auditable. resolveClientIp— socket peer by default;X-Forwarded-Foris only consulted under an explicit trusted-proxy config (hops/cidrs/vercel), so a spoofed XFF can't reach the analysis or the blocklist. XFF entries may carry a port (ip:port,[v6]:port) and may be IPv4-mapped (::ffff:a.b.c.d, read asa.b.c.d). Incidrsmode (and the dashboard's Cloudflare option, which is stored ascidrs) the socket peer must itself be a trusted proxy or a loopback address (a local front end such as App Engine's); otherwise the peer is the client and XFF is ignored. Adapters that have no socket peer (resolveClientIp(null, …), e.g.@camada/next) still walk XFF: they trust the host's network edge, so the app must not be reachable around it. An adapter that has a socket must never pass null for one request whose peer it cannot read. The result is always a valid IP or null: a non-IP peer (a unix socket path,ip:port, a zone id) resolves to null and never walks XFF.
@camada/core/fetch — the shared Web-fetch pipeline
Since 0.3.0 (SDK-G04) the request pipeline every Web-Request/Response adapter needs lives
here, as a second entry point, so a new runtime is a thin binding rather than a copy of
@camada/hono:
import { createFetchCamada, withSetCookie } from '@camada/core/fetch';
const cam = createFetchCamada({ tap: TAP_BUN, sdk: '@camada/bun/0.1.0', iife }, opts);
const r = await cam.before(req, { peer, waitUntil, env }); // { response } | { vars } | null (inert)
if (r?.response) return r.response; // 403, the challenge, the beacon endpoints
const res = await app(req); // keep r.vars in the framework's per-request slot
if (r) return cam.finish(req, r.vars, r.vars.sessionCookie ? withSetCookie(res, r.vars.sessionCookie) : res);finish() never changes what the client receives: the status and every header are the
app's, byte for byte. A server-sent-events response (an explicit content-type:
text/event-stream, no content-length, not a HEAD) comes back with its body re-wrapped in a
pass-through stream, and the event ships when the last chunk has been sent, the client
cancelled, or the body errored, so dur covers the stream. Nothing is buffered. Every other
response comes back as the same object and ships at once, so its dur is the time to first
byte, which for a buffered body is effectively the whole response. That covers fixed-length
bodies, redirects, 1xx/204/304, upgrades and HEAD. Other streamed bodies (streamed HTML,
NDJSON) are timed to their first byte as well. Their wire length cannot be told apart from a
fixed body without reading res.body, and reading it already changes the response on some
hosts (Bun drops the implicit Content-Type, Deno 2.2 the Content-Length). ts is the request
start, so a request ran over [ts, ts + dur].
On workerd an SSE body is piped through the native IdentityTransformStream instead, and the
pipe goes to the adapter's waitUntil (the flush rides it too). workerd never pulls or cancels
a JS stream once the client has gone, so a JS wrapper there would hold waitUntil on nothing,
which workerd reports as a hung Worker. The native pipe errors when the client disconnects,
cancels the source (a proxied upstream SSE closes one chunk behind a camada-free Worker) and
ships the event, its dur ending when workerd noticed. With the enable_request_signal
compatibility flag, request.signal cancels it at once. after(req, vars, status) ships immediately where the
adapter only knows a status (a thrown handler, a websocket upgrade). It reads only vars:
the event is built in before(), because after Deno.upgradeWebSocket the request is closed
and reading its headers throws. status: null means the
response was not seen, so st and dur both ship null.
withSetCookie appends in place when the headers are mutable. When they are immutable (a
fetch() result, Response.redirect()), it rebuilds the response so the client still gets the
same status, body bytes, Content-Encoding and Content-Length. On Deno 2.9, a decoded fetch()
body keeps the upstream gzip headers, which Deno.serve drops only for the original object, so the
copy drops them too. The SSE re-wrap uses the same copy. onBodyDone(res, done, { method,
waitUntil, signal }) from the main entry is the same rule, for pipelines of their own
(@camada/hono). It returns res itself whenever it does not wrap.
The client address is an already-resolved ip the host vouches for, else the socket peer
and X-Forwarded-For under the trusted-proxy rules — never a bare header. track(vars, …) and
scriptTag(vars) are the app-facing helpers, keyed on the vars the adapter kept. mode: 'timer'
polls on an unref'd interval and installs the exit flush (long-lived hosts); the default lazy
refreshes per request through waitUntil (edge and serverless); CAMADA_SERVERLESS=1 forces
lazy. The full pipeline is contracts.md §D.
Conformance fixtures
test/fixtures/ is copied from edge-analyst/fixtures/ (npm run fixtures there), except
poll/, which core owns (the snapshot poll pacing fixture the server SDKs also read): the
expected match results are computed by running the reference blocklist.js itself. blk3/
holds v3 + v4, blk5/ holds v5 with its ordered custom rules (rules.json is the tenant list
behind the container). Re-copy whenever the BLK format, the rule vocabulary or the HDRS order
changes.
Develop
npm install && npm run build && npm test && npm run check