Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e42460e
feat: add outbound proxy support to @arcjet/transport and @arcjet/guard
davidmytton Jun 17, 2026
ec17c13
fix: resolve proxy support CI failures and review feedback
davidmytton Jun 17, 2026
329d125
fix(guard): honor proxies on Deno
arcjet-rei Jun 17, 2026
21f67be
test(transport): cover the HTTPS-through-proxy CONNECT path
arcjet-rei Jun 17, 2026
8c45215
test(transport): exercise Bun and Deno proxying on the real runtimes
arcjet-rei Jun 17, 2026
3ff244c
fix(turbo): declare lowercase proxy env vars
arcjet-rei Jun 17, 2026
7f9585c
fix(transport): don't fail transport creation when the log level is u…
arcjet-rei Jun 17, 2026
ba0c91f
fix(guard): surface invalid base URLs instead of swallowing them
arcjet-rei Jun 17, 2026
df07519
docs: document that NO_PROXY does not support IP/CIDR ranges
arcjet-rei Jun 17, 2026
2400e01
fix(guard): gate the proxy startup line on ARCJET_LOG_LEVEL
arcjet-rei Jun 17, 2026
001c524
test(guard): isolate proxy environment in transport tests
arcjet-rei Jun 17, 2026
9aaf3eb
test(guard): cover the HTTP-proxy agent branch
arcjet-rei Jun 17, 2026
4051908
test(transport): guard against detect-proxy duplication drift
arcjet-rei Jun 17, 2026
125e908
fix(transport,guard): type the proxy env literal so key typos are caught
arcjet-rei Jun 17, 2026
08fef0e
test(transport): verify HTTPS proxy routing via CONNECT, not by disab…
arcjet-rei Jun 17, 2026
c16da28
ci: fix the transport runtime proxy job
arcjet-rei Jun 17, 2026
ea9501c
test(transport): use a node:net CONNECT proxy for cross-runtime support
arcjet-rei Jun 17, 2026
2ab297c
test(transport): set HTTPS_PROXY at startup; lower Bun floor to 1.3.0
arcjet-rei Jun 17, 2026
d878ad5
refactor(transport,guard): have detectProxy take a parsed URL
arcjet-rei Jun 17, 2026
b01b878
refactor: simplify proxy transport selection and NO_PROXY parsing
arcjet-rei Jun 17, 2026
2e91b25
fix(transport,guard): type the agent proxyEnv option for @types/node …
arcjet-rei Jun 18, 2026
218aa13
feat(transport): keep HTTP/2 when proxying via a CONNECT tunnel
arcjet-rei Jun 19, 2026
11987f4
refactor(guard): select the transport by export condition, not runtim…
arcjet-rei Jun 22, 2026
f7ba8df
refactor(transport): dedupe the HTTP/2 setup and tidy the proxy tunnel
arcjet-rei Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/reusable-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,60 @@ jobs:

- name: Run tests
run: npm test

transport-runtime:
name: "@arcjet/transport proxy (${{ matrix.runtime }} ${{ matrix.bun-version || matrix.deno-version }})"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Bun's native-fetch proxy support is exercised here (not under Node).
# `HTTPS_PROXY` is set at process startup (by the npm script), which
# Bun honors back to 1.3.0, the project's minimum supported Bun.
- runtime: bun
script: test-runtime-bun
bun-version: 1.3.0
- runtime: bun
script: test-runtime-bun
bun-version: latest
- runtime: deno
script: test-runtime-deno
deno-version: lts
- runtime: deno
script: test-runtime-deno
deno-version: latest
permissions:
contents: read
steps:
- uses: step-security/harden-runner@a5ad31d6a139d249332a2605b85202e8c0b78450 # v2.19.1
with:
allowed-endpoints: >
api.github.com:443
deno.com:443
dl.deno.land:443
github.com:443
nodejs.org:443
objects.githubusercontent.com:443
registry.npmjs.org:443
release-assets.githubusercontent.com:443
disable-sudo-and-containers: true
egress-policy: block
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: 22
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3 # v2.1.2
if: matrix.runtime == 'bun'
with:
bun-version: ${{ matrix.bun-version }}
- uses: denoland/setup-deno@e95548e56dfa95d4e1a28d6f422fafe75c4c26fb # v2.0.3
if: matrix.runtime == 'deno'
with:
deno-version: ${{ matrix.deno-version }}
# Build the whole workspace first so `@arcjet/transport`'s dependencies
# (`@arcjet/env`, `@arcjet/logger`) are available to the runtime test.
- run: npm ci && npm run build
- run: npm run ${{ matrix.script }} --workspace=@arcjet/transport
27 changes: 27 additions & 0 deletions arcjet-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,33 @@ See the [docs](https://docs.arcjet.com/mcp-server) for setup instructions.

You can also manage sites and keys with the CLI: `npx @arcjet/cli`.

## Proxy support

The standard proxy environment variables (`HTTP_PROXY` and `HTTPS_PROXY`, while
respecting `NO_PROXY`) are auto-detected, making it possible to connect to the
Arcjet API through a proxy such as [Squid](https://www.squid-cache.org/). When a
proxy is in use, a line is logged at startup; the proxy
URL itself is not logged, since it can contain credentials. How the request is
actually proxied depends on the runtime:

- **Node.js** — uses the HTTP/2 transport; when a proxy is detected, requests
are routed through it over HTTP/1.1 using the built-in proxy support of the
Node.js HTTP agent, otherwise made directly over HTTP/2.
- **Bun** — uses the HTTP/2 transport directly, but its Node HTTP agent doesn't
support proxying, so when a proxy is detected it falls back to the fetch-based
transport and Bun's `fetch` performs the proxying natively.
- **Deno** — the runtime's `fetch` performs the proxying natively.
- **Cloudflare Workers** and other edge runtimes don't support outbound proxy
environment variables, so no proxy is used.

`NO_PROXY` accepts a comma- or space-separated list of host suffixes, each with
an optional leading `.` or `*.` and an optional `:port`, plus `*` to bypass the
proxy for every host. Entries are matched as host names; IP/CIDR ranges (such as
`10.0.0.0/8`) are not supported, the same as
[curl](https://curl.se/docs/manpage.html#--noproxy). On Bun and Deno the
runtime's `fetch` applies `NO_PROXY` itself, so its exact semantics are the
runtime's.

## Runtime support

| Runtime | Minimum version |
Expand Down
12 changes: 10 additions & 2 deletions arcjet-guard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@
"exports": {
".": {
"bun": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
"types": "./dist/bun.d.ts",
"import": "./dist/bun.js"
},
"edge-light": {
"types": "./dist/fetch.d.ts",
Expand All @@ -38,6 +38,10 @@
"types": "./dist/fetch.d.ts",
"import": "./dist/fetch.js"
},
"deno": {
"types": "./dist/fetch.d.ts",
"import": "./dist/fetch.js"
},
"node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
Expand All @@ -51,6 +55,10 @@
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
},
"./bun": {
"types": "./dist/bun.d.ts",
"import": "./dist/bun.js"
},
"./fetch": {
"types": "./dist/fetch.d.ts",
"import": "./dist/fetch.js"
Expand Down
43 changes: 43 additions & 0 deletions arcjet-guard/src/bun.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";

import {
launchArcjet,
createTransport,
tokenBucket,
fixedWindow,
slidingWindow,
detectPromptInjection,
localDetectSensitiveInfo,
defineCustomRule,
launchArcjetWithTransport,
} from "./bun.ts";

describe("bun entrypoint", () => {
test("launchArcjet is exported as a function", () => {
assert.equal(typeof launchArcjet, "function");
});

test("createTransport is re-exported", () => {
assert.equal(typeof createTransport, "function");
});

test("rule factories are re-exported", () => {
assert.equal(typeof tokenBucket, "function");
assert.equal(typeof fixedWindow, "function");
assert.equal(typeof slidingWindow, "function");
assert.equal(typeof detectPromptInjection, "function");
assert.equal(typeof localDetectSensitiveInfo, "function");
assert.equal(typeof defineCustomRule, "function");
});

test("launchArcjetWithTransport is re-exported", () => {
assert.equal(typeof launchArcjetWithTransport, "function");
});

test("launchArcjet returns an object with .guard()", () => {
const arcjet = launchArcjet({ key: "ajkey_test" });

assert.equal(typeof arcjet.guard, "function");
});
});
195 changes: 195 additions & 0 deletions arcjet-guard/src/bun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/**
* `@arcjet/guard/bun` — Bun entrypoint.
*
* Bun resolves the `"."` export here. Uses HTTP/2 via `node:http2`
* (`@connectrpc/connect-node`) for optimal performance with long-lived
* connections and optimistic pre-connect — Bun's `fetch` does not support
* HTTP/2 ({@link https://github.com/oven-sh/bun/issues/7194}). When a proxy is
* configured, it falls back to the fetch transport so Bun's native `fetch`
* performs the proxying.
*
* **Lifecycle:** Create the client once at module scope and reuse it.
* The underlying HTTP/2 transport maintains a persistent connection;
* creating a new client per request wastes that connection.
*
* @example
* ```ts
* import { launchArcjet, tokenBucket, detectPromptInjection } from "@arcjet/guard";
*
* // Create the client once at module scope
* const arcjet = launchArcjet({ key: "ajkey_..." });
*
* // Configure reusable rules (also at module scope)
* const limitRule = tokenBucket({ bucket: "user-tokens", refillRate: 10, intervalSeconds: 60, maxTokens: 100 });
* const piRule = detectPromptInjection();
*
* // Per request — create rule inputs each time
* const rl = limitRule({ key: userId, requested: tokenCount });
* const decision = await arcjet.guard({
* label: "tools.weather",
* rules: [rl, piRule(userMessage)],
* });
*
* // Overall decision
* if (decision.conclusion === "DENY") {
* console.log(decision.reason); // "RATE_LIMIT", "PROMPT_INJECTION", etc.
* }
*
* // Check for errors (fail-open — errors don't cause denials)
* if (decision.hasError()) {
* console.warn("At least one rule errored");
* }
*
* // Per-rule results
* for (const result of decision.results) {
* console.log(result.type, result.conclusion);
* }
*
* // From a RuleWithInput — result for this specific submission
* const r = rl.result(decision);
* if (r) {
* console.log(r.remainingTokens, r.maxTokens);
* }
*
* // From a RuleWithConfig — first denied result across all submissions
* const denied = limitRule.deniedResult(decision);
* if (denied) {
* console.log(denied.remainingTokens); // 0
* }
* ```
*
* Unlike some other `@arcjet/*` packages, `@arcjet/guard` never reads
* environment variables directly. All configuration must be passed
* explicitly via `launchArcjet()` options, `.guard()`, or rule inputs.
*
* @packageDocumentation
*/

export {
// Types
type Conclusion,
type Reason,
type Mode,
type RuleResult,
type RuleResultTokenBucket,
type RuleResultFixedWindow,
type RuleResultSlidingWindow,
type RuleResultPromptInjection,
type RuleResultSensitiveInfo,
type RuleResultCustom,
type RuleResultNotRun,
type RuleResultError,
type RuleResultUnknown,
type Decision,
type DecisionAllow,
type DecisionDeny,
type DecisionBase,
type RuleWithInput,
type RuleWithConfig,
type GuardOptions,
type LaunchOptions,
type ArcjetGuard,

// Rule config types
type TokenBucketConfig,
type TokenBucketInput,
type FixedWindowConfig,
type FixedWindowInput,
type SlidingWindowConfig,
type SlidingWindowInput,
type DetectPromptInjectionConfig,
type LocalDetectSensitiveInfoConfig,
type SensitiveInfoEntityType,
type LocalCustomConfig,
type LocalCustomInput,

// Rule factories
tokenBucket,
fixedWindow,
slidingWindow,
detectPromptInjection,
localDetectSensitiveInfo,
defineCustomRule,

// Transport-agnostic factory
launchArcjetWithTransport,

// Internal
_launchWithTransportFactory,
} from "./index.ts";

import { _launchWithTransportFactory } from "./index.ts";
import type { LaunchOptions, ArcjetGuard } from "./index.ts";
import { createTransport } from "./transport-bun.ts";

/**
* Create an Arcjet guard client using the Bun transport.
*
* Connects over HTTP/2 by default, falling back to a fetch-based transport when
* a proxy is configured so Bun's native `fetch` performs the proxying.
*
* Connect to the Arcjet MCP server at `https://api.arcjet.com/mcp` to manage
* sites, retrieve SDK keys, and more. Learn more at
* {@link https://docs.arcjet.com/mcp-server}.
*
* **Create once, reuse everywhere.** The returned client holds a
* persistent HTTP/2 connection that is optimistically pre-connected.
* Wrapping this in a function that creates a new client per request
* defeats connection reuse and adds latency.
*
* Three lifetimes to keep in mind:
* 1. **Client** (`launchArcjet`) — create once at module scope.
* 2. **Rule config** (`tokenBucket(...)`) — create once at module scope (recommended).
* 3. **Rule input** (`limitRule({ key })`) — create per request / tool call.
*
* @example
* ```ts
* import { launchArcjet, tokenBucket, detectPromptInjection } from "@arcjet/guard";
*
* // Create the client once at module scope
* const arcjet = launchArcjet({ key: "ajkey_..." });
*
* // Configure reusable rules (also at module scope)
* const limitRule = tokenBucket({ bucket: "user-tokens", refillRate: 10, intervalSeconds: 60, maxTokens: 100 });
* const piRule = detectPromptInjection();
*
* // Per request — create rule inputs each time
* const rl = limitRule({ key: userId, requested: tokenCount });
* const decision = await arcjet.guard({
* label: "tools.weather",
* rules: [rl, piRule(userMessage)],
* });
*
* // Overall decision
* if (decision.conclusion === "DENY") {
* console.log(decision.reason); // "RATE_LIMIT", "PROMPT_INJECTION", etc.
* }
*
* // Check for errors (fail-open — errors don't cause denials)
* if (decision.hasError()) {
* console.warn("At least one rule errored");
* }
*
* // Per-rule results
* for (const result of decision.results) {
* console.log(result.type, result.conclusion);
* }
*
* // From a RuleWithInput — result for this specific submission
* const r = rl.result(decision);
* if (r) {
* console.log(r.remainingTokens, r.maxTokens);
* }
*
* // From a RuleWithConfig — first denied result across all submissions
* const denied = limitRule.deniedResult(decision);
* if (denied) {
* console.log(denied.remainingTokens); // 0
* }
* ```
*/
export function launchArcjet(options: LaunchOptions): ArcjetGuard {
return _launchWithTransportFactory(createTransport, options);
}

export { createTransport } from "./transport-bun.ts";
Loading
Loading