A rules-driven caching component for Harper that sits between edge traffic and origin services. It supports:
- HTML/page caching and API caching in separate Harper tables.
- A DB-backed TTL rule engine (regex + optional header/query conditions).
- Cache bypass and debug observability headers.
- Manual and timestamp-based invalidation.
- Environment-based configuration via
cacheConfiguration.<env>.jsonfiles, selected at boot by theENVIRONMENTenv var.
- Architecture in Harper
- Request Flow
- TTL Rules Engine
- Harper Schema
- Cache Configuration Reference
- Environment Variables
- Admin Resources
- Invalidation Model
- Headers and Observability
- Authentication Model
- Run and Deploy
- Operational Notes
This component is implemented as:
- A global HTTP interceptor in
src/index.tsusingserver.http(...). - Cache handler modules in
src/cacheHandlers/:defaultCache.ts— page/HTML cache using Harper'ssourcedFromexternal data source patternapiCache.ts— API response cache using the same pattern
- Custom resource classes for rule management and invalidation:
TTLRulesinsrc/resources/ttlRules.tsInvalidateinsrc/resources/cacheInvalidation.ts
- Utility modules for:
- rule classification and cache entry fetching (
src/util/cache.ts) - key generation (
src/util/cacheKeys.ts) - header handling (
src/util/headers.ts) - origin fetch pooling with connection reuse and timeouts (
src/util/originClient.ts)
- rule classification and cache entry fetching (
Runtime bootstrap behavior:
- Load request interceptor.
- Subscribe to TTL rule updates from
TTLRulesand rebuild in-memory index. - Subscribe to invalidation timestamps and keep an in-memory invalidation map.
Incoming traffic is processed by server.http(...):
- Reserved paths bypass cache routing (always bypassed):
/status/prometheus_exporter/metrics/cache/ttlConfig/cache/invalidate- Additional paths can be added via the
RESERVED_PATHSenv var (comma-separated).
- Other requests are authenticated using
authorization(Basic auth). - Requests are classified as API traffic when either condition matches:
- a configurable header (
CACHE_CONFIG.apiHeader) - URL contains configured API prefix (
CACHE_CONFIG.apiPathPrefix)
- a configurable header (
- API requests route to
handleAPI(...); cacheable page requests route tofetchCachedResponse(...), non-cacheable tooriginPassthrough(...).
Keys are deterministic and configuration-driven:
- Path is normalized:
- lowercased
- canonical trailing slash behavior
- Included headers/cookies/query params are controlled by:
defaultCacheKeyfor page cacheapiCacheKeyfor API cache
- Key parts are sorted for order-independent equality.
- When key length exceeds
KEY_OVERFLOW(1000 chars), a suffix MD5 hash is added.
Each request is classified against in-memory TTL rules:
- Path is normalized.
- Candidates are narrowed by longest literal prefix bucket.
- Candidates are checked in descending specificity.
- Optional conditions are evaluated (
header/query+ operator). - First matching rule wins.
- Result is memoized for hot keys (up to 5000 entries) when rule has no conditions.
GETrequests can use cache unlessx-harper-cache-bypass: true.- Cache hit returns stored payload + stored headers +
x-harper-cache: hit. - Cache miss fetches origin, conditionally stores successful responses (
status === 200and matching TTL rule), returnsx-harper-cache: miss. - Non-cacheable API responses return
x-harper-cache: no-cache. - Non-GET API methods are proxied without caching.
- Computes page cache key and rule match.
- If cacheable and present, returns cached content (
x-harper-cache: hit). - Otherwise fetches origin and stores response when rule exists.
- Unsuccessful origin responses are not cached.
Both flows:
- Strip/normalize non-cache-safe headers.
- Persist optional
groupCode,cacheTags,url, andrefreshedAt. - Use invalidation timestamps to skip stale entries.
TTL rules are defined per row and loaded into memory from the TTL rules table.
Each rule row supports:
iddescriptionpathPatterns: array of regex stringsttl: duration or special policygroupCode(optional)additionalMatchCriteria(optional)
Supported rule policies in runtime:
- Duration policy: numeric duration converted to seconds.
origin_expires: use upstreamExpiressemantics.never: no expiration timestamp is set.no_cache: explicitly disallow caching
Validation on the admin resource currently accepts:
- Durations:
1m,6h,1d,1y(pattern: positive integer +m|h|d|y) - Specials:
origin_expires,never
Note: no_cache is a runtime-only policy and cannot be submitted via the admin API.
Note:
- If no TTL rule matches, the request is treated as non-cacheable by default.
additionalMatchCriteria entries support:
additionalMatchType:headerorqueryadditionalMatchOperator:equals,not_equals,contains,not_contains,exists,not_existsadditionalMatchKeyadditionalMatchValue: string or string array for value-based operators
Current evaluation behavior:
- Multiple criteria in one rule are ANDed.
equalsandnot_equalscompare against a single normalized value.containsandnot_containsare evaluated against provided value list.existsandnot_existsonly require key presence/absence.
Indexing and ranking:
- Regexes are compiled once.
- A literal prefix is extracted from each regex when possible.
- Prefix buckets are sorted longest-first.
- Specificity scoring:
- base from literal density and path segments
- +10 weight when conditions are present
- Candidate order is "bucket candidates first, then general rules", each already sorted by specificity.
Memoization:
- In-memory map max size: 5000.
- FIFO-style eviction at overflow.
- Memo is cleared when rules reload.
- Rules with additional conditions are not memoized.
Source: src/db/schema.graphql
| Field | Type | Notes |
|---|---|---|
cacheKey |
String |
Primary key |
data |
Blob! |
Cached page payload |
headers |
String! |
Serialized response headers |
debugHeaders |
String |
Serialized debug metadata |
groupCode |
String |
Optional grouped invalidation key |
cacheTags |
String |
Optional cache tags extracted from origin header |
url |
String |
Source URL |
refreshedAt |
Long |
Last write timestamp |
| Field | Type | Notes |
|---|---|---|
cacheKey |
String |
Primary key |
data |
Blob! |
Cached API payload |
headers |
String! |
Serialized response headers |
debugHeaders |
String |
Serialized debug metadata |
groupCode |
String |
Optional grouped invalidation key |
cacheTags |
String |
Optional cache tags |
url |
String |
Source URL |
refreshedAt |
Long |
Last write timestamp |
| Field | Type | Notes |
|---|---|---|
id |
ID |
Primary key |
description |
String |
Human-readable label |
pathPatterns |
[String]! |
Regex list |
ttl |
String! |
Duration or special policy |
groupCode |
String |
Optional grouped invalidation key |
additionalMatchCriteria |
[Any] |
Optional conditional matching criteria |
| Field | Type | Notes |
|---|---|---|
id |
Int |
Primary key (record 1 is used by invalidation flow) |
timestamps |
Any |
Map of invalidation timestamps by key (api, page, or groupCode) |
Configuration is split into per-environment files named cacheConfiguration.<env>.json. The active file is selected at boot using the ENVIRONMENT environment variable:
ENVIRONMENT=prod # loads cacheConfiguration.prod.json
ENVIRONMENT=stage # loads cacheConfiguration.stage.json
ENVIRONMENT=integration # loads cacheConfiguration.integration.json
# unset or empty # defaults to cacheConfiguration.local.jsonCreate one file per environment you deploy to. A minimal set looks like:
cacheConfiguration.local.json ← local dev (default when ENVIRONMENT is unset)
cacheConfiguration.stage.json
cacheConfiguration.prod.json
cacheConfiguration.integration.json ← used when running integration tests
If the resolved file does not exist, the app will throw at startup with a clear error indicating which file it tried to load.
Set the ENVIRONMENT variable wherever your Harper process is launched. Examples:
Shell / systemd:
ENVIRONMENT=prod harperdb run .Docker:
ENV ENVIRONMENT=proddocker-compose:
environment:
- ENVIRONMENT=prodHarper config.yaml (via loadEnv.files: .env):
# .env
ENVIRONMENT=prod{
"cacheTagsHeader": "X-Origin-Cache-Tags",
"apiPathPrefix": "/api/",
"apiHeader": { "key": "X-Fwd-Origin", "value": "API" },
"apiPathReplacement": { "search": "/api/", "replace": "" },
"apiOrigin": "https://api.staging.example.com",
"apiOriginAuthHeader": "",
"apiCacheKey": {
"includeHeaders": ["accept", "origin", "version"],
"includeQueryParams": "ALL",
"includeCookies": []
},
"defaultOrigin": "https://www.harper.fast",
"defaultOriginAuthHeader": "",
"defaultPathReplacement": false,
"defaultCacheKey": {
"includeHeaders": ["device-type", "accept-language"],
"includeQueryParams": ["sort", "page", "filter"],
"includeCookies": ["brand"]
}
}| Key | Purpose | Required |
|---|---|---|
cacheTagsHeader |
Response header name read from origin to persist cache tags in records. | No |
apiPathPrefix |
URL substring used to classify requests as API traffic. | Conditional: required if apiOrigin is set and apiHeader is not set |
apiHeader |
Header-based API classifier object (key + value). |
Conditional: required if apiOrigin is set and apiPathPrefix is not set |
apiPathReplacement |
Rewrites incoming API path before forwarding to API origin. | No |
apiOrigin |
API origin base URL string (e.g. https://api.example.com). |
No |
apiOriginAuthHeader |
Optional header name sent to API origin for auth token forwarding. | Optional; if set, token env var is required |
apiCacheKey |
API cache key config object (includeHeaders, includeQueryParams, includeCookies). |
Conditional: required if apiOrigin is set |
defaultOrigin |
Default/page origin base URL string (e.g. https://www.example.com). |
Yes |
defaultOriginAuthHeader |
Optional header name sent to default origin for auth token forwarding. | Optional; if set, token env var is required |
defaultPathReplacement |
Rewrites default/page path before forwarding to default origin. | No |
defaultCacheKey |
Page cache key config object (includeHeaders, includeQueryParams, includeCookies). |
Yes |
If apiOriginAuthHeader is set, provide one of as an env var:
HDB_API_ORIGIN_AUTH_TOKENAPI_ORIGIN_AUTH_TOKEN
If defaultOriginAuthHeader is set, provide one of as an env var:
HDB_DEFAULT_ORIGIN_AUTH_TOKENDEFAULT_ORIGIN_AUTH_TOKEN
All variables are optional unless noted.
| Variable | Default | Description |
|---|---|---|
ENVIRONMENT |
local |
Selects the cacheConfiguration.<env>.json file to load at startup. |
REQUEST_TIMEOUT_MS |
30000 |
Outer per-request timeout (ms). Requests exceeding this return 504 Gateway Timeout. |
MAX_CONNECTIONS |
80 |
Max simultaneous connections per origin in the undici pool. |
CLIENT_TTL_MS |
300000 |
Keep-alive timeout for pooled connections (ms). |
CONNECT_TIMEOUT_MS |
10000 |
TCP connect timeout per origin request (ms). |
HEADERS_TIMEOUT_MS |
30000 |
Time to wait for response headers from origin (ms). |
BODY_TIMEOUT_MS |
60000 |
Time to wait for the full response body from origin (ms). |
RESERVED_PATHS |
(none) | Comma-separated additional URL paths that bypass cache logic entirely (e.g. /health,/ping). |
HDB_LOAD_TEST_MODE |
false |
When true, replaces all origin fetches with mock responses for load testing without a real origin. |
API_ORIGIN_AUTH_TOKEN |
(none) | Auth token forwarded to the API origin when apiOriginAuthHeader is configured. |
HDB_API_ORIGIN_AUTH_TOKEN |
(none) | Same as above; takes priority over API_ORIGIN_AUTH_TOKEN. |
DEFAULT_ORIGIN_AUTH_TOKEN |
(none) | Auth token forwarded to the default origin when defaultOriginAuthHeader is configured. |
HDB_DEFAULT_ORIGIN_AUTH_TOKEN |
(none) | Same as above; takes priority over DEFAULT_ORIGIN_AUTH_TOKEN. |
The module exports:
cache.ttlConfigfor TTL rule writescache.invalidatefor cache invalidation operations
Path mapping for exported Resources:
POST /cache/ttlConfigPUT /cache/ttlConfig/:idPOST /cache/invalidate
{
"description": "Category API responses",
"pathPatterns": ["^.*/catalog/v\\d+/category/.+$"],
"ttl": "6h",
"groupCode": "catalog",
"additionalMatchCriteria": [
{
"additionalMatchType": "query",
"additionalMatchOperator": "equals",
"additionalMatchKey": "catNav",
"additionalMatchValue": "L4"
}
]
}Expected behavior:
POST: create rulePUT: upsert by:id- Validation errors return
400with message text. - Success returns
204.
cache.invalidate expects JSON body:
{
"type": "api | page | cacheTag | url",
"groupCode": "optional-group",
"cacheTag": "required-when-type-cacheTag",
"url": "required-when-type-url",
"runAsync": false
}- Does not delete cache rows.
- Writes an invalidation timestamp into
CacheInvalidation.timestamps. - The app subscribes to this table and keeps the latest timestamps in memory.
- Any cache record with
refreshedAt < timestampis treated as expired on read. - If
groupCodeis provided, the timestamp is stored bygroupCodeinstead of globalapi/pagekey, Invalidating only records with matching group code within the default or api cache. - This model reduces write volume for mass invalidation because it avoids record-by-record deletes.
- Immediately deletes records in both cache tables matching
cacheTags contains <tag>. - Note: this requires a scan of the relevant cache table(s) to find matching records, which can be expensive at scale.
- Set
runAsync: trueto return a202 Acceptedimmediately and run the deletes in the background.
- Immediately deletes records in both cache tables matching
url == <url>. - Set
runAsync: trueto return a202 Acceptedimmediately and run the deletes in the background.
- You can directly delete a single cache row by
cacheKeyusing Harper Operations APIdelete. - Operations API reference: Delete (NoSQL)
x-harper-cache-bypass: true- Bypass cache and fetch origin directly.
x-harper-cache-debug: true- Include detailed cache debug headers in response.
x-harper: truex-harper-cache: hit | miss- Debug headers when enabled:
x-harper-cache-pathx-harper-cache-rulex-harper-cache-rule-idx-harper-cache-policyx-harper-cache-ttlx-harper-cache-bucketx-harper-cache-patternx-harper-cache-keyx-harper-cache-ttl-remaining-sec
For non-reserved paths, the interceptor reads:
authorization: Basic <base64(username:password)>
Then calls server.authenticateUser(username, password).
Example:
HDB_ADMIN:password -> Basic SERCX0FETUlOOnBhc3N3b3Jk
authorization: Basic SERCX0FETUlOOnBhc3N3b3JkThe component enforces role-based access using two role sets:
ALLOWED_ROLES_CACHE = ['cache_user', 'cache_admin', 'super_user'];
ALLOWED_ROLES_ADMIN = ['cache_admin', 'super_user'];ALLOWED_ROLES_CACHE— required for all cache proxy requests. Users must hold one of these roles to have their requests served.ALLOWED_ROLES_ADMIN— required for admin operations such as TTL rule management (/cache/ttlConfig) and cache invalidation (/cache/invalidate).
Requests authenticated with a user that does not hold the required role will be rejected. To grant access to a user:
- Create the user in Harper with the appropriate role, or assign an existing user one of the roles above.
- Harper's built-in
super_userrole has full access. For cache-only access usecache_user; for admin access usecache_admin.
See Configuring Roles in the Harper documentation for how to create and assign custom roles.
npm install
npm run build
npm run devnpm run test:unitconfig.yaml currently sets:
rest: truegraphqlSchema.files: src/db/schema.graphqljsResource.files: dist/resources/index.jsloadEnv.files: .env
Ensure build output and resource entrypoints are aligned with your deployment target.
- Rules are hot-reloaded via table subscriptions; restart is not required for rule edits.
- Invalidation timestamps are in-memory plus table-backed; using record id
1keeps semantics simple. - Cache keys intentionally include only configured dimensions to avoid cardinality blowups.
- Keep regex patterns as specific as possible to minimize candidate scans and false matches.
Validates isolated utility and rules logic.
npm run test:unitValidates end-to-end cache behavior against a running Harper instance with mocked origins.
npm run test:integrationMinimum local environment (in the shell running tests):
export TEST_DOMAIN=http://localhost:9926
export HDB_ADMIN_USERNAME=HDB_ADMIN
export HDB_ADMIN_PASSWORD=passwordHarper must be running with ENVIRONMENT=integration (loads cacheConfiguration.integration.json):
# Shell running Harper
export ENVIRONMENT=integration
harperdb run .The mock origin host and port are configurable via env vars in the test shell if your network differs from the defaults (e.g. non-Docker local setups):
# Shell running tests — override if mock origins are not on the default addresses
export MOCK_ORIGIN_HOST=127.0.0.1
export MOCK_DEFAULT_ORIGIN_PORT=4101
export MOCK_API_ORIGIN_PORT=4102If you need a fully local setup with different origin URLs, create a cacheConfiguration.local.json pointing to your local mock addresses and run Harper with ENVIRONMENT=local (or unset).
See tests/integration/README.md for the full local two-terminal setup.
Ramps to target RPS and round-robins requests across provided hosts.
k6 run tests/performance/ramp-round-robin.test.js \
-e HOSTS=https://localhost:9926,https://localhost:9936 \
-e REQUEST_PATHS=/,/api/it/load/products?foo=bar \
-e TARGET_RPS=250 \
-e RAMP_UP_DURATION=2m \
-e TARGET_DURATION=8mTo simulate origin behavior without calling external origins, run Harper with:
export HDB_LOAD_TEST_MODE=trueSee tests/performance/README.md for all k6 options.