Skip to main content
mod_pagespeed 1.15 — Cyclone Cache, modernized optimizations, IIS + .NET support, and six years of security updates
2.0 1.1

API Reference: IPC, Sockets, and C API

Reference for ModPageSpeed 2.0 internal interfaces: the nginx-to-worker IPC wire format, the health and management sockets (STATS, PURGE, METRICS), the capability-mask encoding, and the ps_ C API.

On this page

This page documents the internal protocols and interfaces of ModPageSpeed 2.0: the IPC wire format between nginx and the worker, the health check endpoint, the management socket protocol (STATS, PURGE, METRICS, BROWSER-STATUS), the capability mask encoding, and the ps_ C API for embedding the cache and HTML processor into other applications.

IPC Wire Format

The nginx module sends fire-and-forget notifications to the worker over a Unix socket. There is no response — the worker reads the notification, processes the content from the shared cache, and writes optimized variants back.

Message Structure

Each message consists of a 4-byte length header followed by the payload:

[4 bytes: payload_length (big-endian uint32)]
[4 bytes: url_length (big-endian uint32)]
[url_length bytes: URL string]
[4 bytes: hostname_length (big-endian uint32)]
[hostname_length bytes: hostname string]
[1 byte: content_type]
[4 bytes: capability_mask (big-endian uint32)]

The payload_length field does not include itself — it is the total size of everything after the first 4 bytes. The hostname is required for cache key computation (SHA-256(URL, hostname)).

Content Type Values

ValueType
0HTML
1CSS
2JavaScript
3Image
4Other

Example

For URL /style.css (10 bytes), hostname localhost (9 bytes), content type CSS (0x01), capability mask 0x00000008 (Desktop/Identity):

00 00 00 20          # payload_length = 32 bytes
00 00 00 0A          # url_length = 10
2F 73 74 79 6C 65 2E 63 73 73  # "/style.css"
00 00 00 09          # hostname_length = 9
6C 6F 63 61 6C 68 6F 73 74     # "localhost"
01                   # content_type = CSS
00 00 00 08          # capability_mask = 0x08

Connection Lifecycle

  1. Nginx opens a new Unix socket connection for each notification.
  2. Nginx writes the serialized message.
  3. Nginx closes the connection (fire-and-forget).
  4. On connection failure (ECONNREFUSED, ENOENT), nginx retries with exponential backoff: 2 retries, 10ms initial delay, 3x multiplier (10ms, 30ms).
  5. If all retries fail, the notification is dropped. The cache serves the original content. Subsequent requests for the same URL re-notify.

Health Endpoint

The worker listens on a Unix socket at {socket_path}.health. On connection, it immediately writes a status line and closes.

Socket Path

If the worker socket is /var/lib/pagespeed/pagespeed.sock, the health socket is /var/lib/pagespeed/pagespeed.sock.health.

Response Format

OK {active_connections}/{max_connections} notifs={N} variants={N} proactive={N} errors={N} cache_entries={N}\n

Fields

FieldDescription
active_connectionsNumber of currently active client connections
max_connectionsConfigured maximum connections (--max-connections)
notifsTotal notifications received from nginx
variantsTotal optimized variants written to cache
proactiveProactively generated sibling variants
errorsTotal processing errors
cache_entriesCurrent number of entries in the Cyclone cache

Example

OK 3/128 notifs=4821 variants=2106 proactive=1580 errors=7 cache_entries=3412

Usage

python3 -c "
import socket
s = socket.socket(socket.AF_UNIX)
s.connect('/var/lib/pagespeed/pagespeed.sock.health')
print(s.recv(256).decode())
s.close()
"

The health endpoint is designed for automated health checks (Docker HEALTHCHECK, Kubernetes liveness probes, load balancer checks). It accepts a connection, writes the response, and closes. No request data is needed.

Management Socket Protocol

The worker listens on a Unix socket at {socket_path}.mgmt for newline-terminated text commands. After processing a command, the worker writes a response and closes the connection.

Socket Path

If the worker socket is /var/lib/pagespeed/pagespeed.sock, the management socket is /var/lib/pagespeed/pagespeed.sock.mgmt.

Commands

STATS

Returns a JSON object with worker statistics: connections, notifications, variants, errors, cache, and per-type and per-format breakdowns.

Request: STATS\n

Response:

{
  "status": "ok",
  "connections": {
    "active": 2,
    "max": 128
  },
  "notifications": {
    "received": 4821,
    "skipped_dedup": 1200
  },
  "variants": {
    "written": 2106,
    "proactive": 1580
  },
  "errors": 7,
  "cache": {
    "entries": 3412,
    "size": 268435456
  },
  "by_type": {
    "html": { "n": 120, "us": 450000 },
    "css": { "n": 340, "us": 180000 },
    "js": { "n": 280, "us": 150000 },
    "images": { "n": 1366, "us": 28500000 }
  },
  "by_format": {
    "webp": 580,
    "avif": 490,
    "jpeg": 180,
    "png": 116
  },
  "timing_us": {
    "total": 29280000
  }
}

Field descriptions:

FieldDescription
connections.activeCurrently open client connections
connections.maxConfigured --max-connections
notifications.receivedTotal notifications from nginx
notifications.skipped_dedupSkipped because variant already exists
variants.writtenTotal optimized variants written to cache
variants.proactiveSubset of written that were sibling variants
errorsTotal processing failures
cache.entriesCurrent number of cache entries
cache.sizeCurrent cache size in bytes
by_type.{type}.nNumber of items processed for this content type
by_type.{type}.usCumulative processing time in microseconds
by_format.{format}Number of variants generated per image format
timing_us.totalCumulative processing time across all types

PURGE <hostname> <url>

Deletes all cached variants for the specified URL and hostname.

Request: PURGE example.com /images/photo.jpg\n

Response: OK N entries deleted\n

The PURGE command iterates all possible capability mask combinations (image formats, viewports, pixel densities, Save-Data states, transfer encodings) and deletes every cache entry for the URL. It also deletes sentinel entries used for Early Hints (0x1C) and warmup (0x2C).

The maximum number of entries deleted per URL is 192 (4 formats x 3 viewports x 2 densities x 2 Save-Data x 4 encodings) plus 2 sentinel entries = 194.

AUTH <token>

Authenticates the connection for PURGE commands. Required when the PAGESPEED_PURGE_TOKEN environment variable is set on the worker.

Request: AUTH my-secret-token\n

Response: OK\n on success, or ERR invalid token\n on failure.

Send AUTH as the first command on each connection before issuing PURGE. The token is compared using constant-time comparison to prevent timing attacks. STATS and METRICS commands work without authentication regardless of whether the env var is set.

When PAGESPEED_PURGE_TOKEN is not set, PURGE works without authentication (backward compatible).

METRICS

Returns worker statistics in Prometheus text exposition format. This enables direct Prometheus scraping via a simple exporter script.

Request: METRICS\n

Response:

# HELP pagespeed_notifications_total Total notifications received.
# TYPE pagespeed_notifications_total counter
pagespeed_notifications_total 4821
# HELP pagespeed_notifications_skipped_total Notifications skipped (dedup).
# TYPE pagespeed_notifications_skipped_total counter
pagespeed_notifications_skipped_total 1200
# HELP pagespeed_variants_written_total Total variants written to cache.
# TYPE pagespeed_variants_written_total counter
pagespeed_variants_written_total 2106
# HELP pagespeed_proactive_variants_total Proactively generated variants.
# TYPE pagespeed_proactive_variants_total counter
pagespeed_proactive_variants_total 1580
# HELP pagespeed_errors_total Total processing errors.
# TYPE pagespeed_errors_total counter
pagespeed_errors_total 7
# HELP pagespeed_processing_seconds_total Cumulative processing time in seconds.
# TYPE pagespeed_processing_seconds_total counter
pagespeed_processing_seconds_total{type="html"} 0.450000
pagespeed_processing_seconds_total{type="css"} 0.180000
pagespeed_processing_seconds_total{type="js"} 0.150000
pagespeed_processing_seconds_total{type="image"} 28.500000
# HELP pagespeed_format_generated_total Variants generated by image format.
# TYPE pagespeed_format_generated_total counter
pagespeed_format_generated_total{format="webp"} 580
pagespeed_format_generated_total{format="avif"} 490
pagespeed_format_generated_total{format="jpeg"} 180
pagespeed_format_generated_total{format="png"} 116
# HELP pagespeed_cache_entries Current number of cache entries.
# TYPE pagespeed_cache_entries gauge
pagespeed_cache_entries 3412
# HELP pagespeed_cache_bytes Current cache size in bytes.
# TYPE pagespeed_cache_bytes gauge
pagespeed_cache_bytes 268435456
# HELP pagespeed_connections_active Currently active connections.
# TYPE pagespeed_connections_active gauge
pagespeed_connections_active 3
# HELP pagespeed_connections_max Maximum allowed connections.
# TYPE pagespeed_connections_max gauge
pagespeed_connections_max 128

Prometheus scraping example:

# One-liner to scrape metrics via socat
echo "METRICS" | socat -t 5 - UNIX-CONNECT:/var/lib/pagespeed/pagespeed.sock.mgmt

For a Prometheus exporter, use a simple script that connects to the management socket and exposes the output on an HTTP endpoint.

BROWSER-STATUS

Returns a JSON object with browser analysis status. If browser analysis is not enabled, returns {"enabled":false}.

Request: BROWSER-STATUS\n

Response:

{
  "enabled": true,
  "chrome_running": true,
  "chrome_pid": 12345,
  "chrome_rss_mb": 180,
  "queue_depth": 2,
  "analysis_in_progress": true,
  "templates_tracked": 14,
  "profiles_generated": 87,
  "profiles_used": 62,
  "analysis_errors": 3,
  "chrome_restarts": 1,
  "chrome_crashes": 0,
  "queue_enqueued": 120,
  "queue_dropped": 5,
  "queue_processed": 113,
  "css_inlining_attempted": 87,
  "css_inlining_stylesheets_found": 340,
  "css_inlining_stylesheets_cached": 298,
  "css_inlining_bytes_inlined": 145200,
  "reanalyses_scheduled": 12,
  "scripts_analyzed": 156,
  "scripts_deferrable": 42
}
FieldDescription
chrome_runningWhether the managed Chrome instance is alive
chrome_pidOS process ID of Chrome (0 if not running)
chrome_rss_mbChrome resident memory in megabytes
queue_depthURLs currently waiting for analysis
analysis_in_progressWhether an analysis is actively running
templates_trackedNumber of page templates detected
profiles_generatedTotal CSS coverage profiles completed
profiles_usedProfiles that resulted in critical CSS injection
analysis_errorsTotal analysis failures
chrome_restartsTimes Chrome was restarted (planned)
chrome_crashesTimes Chrome crashed unexpectedly
queue_enqueued / queue_dropped / queue_processedQueue lifecycle counters
css_inlining_*Critical CSS inlining statistics
reanalyses_scheduledPages queued for re-analysis (e.g., after CSS changes)
scripts_analyzed / scripts_deferrableScript deferral analysis counters

Unknown Commands

Any command other than AUTH, STATS, PURGE, METRICS, or BROWSER-STATUS returns:

ERR unknown command\n

Connection Safety

The management socket disconnects clients whose buffer exceeds 16 KB without containing a newline. This prevents denial-of-service from malformed input.

Capability Mask Encoding

The 32-bit capability mask encodes client characteristics as a bitmask. Only the lowest 8 bits are used; bits 8-31 are reserved and must be zero (except for sentinel values).

Bit Layout

Bit:    7    6    5    4    3    2    1    0
      +----+----+----+----+----+----+----+----+
      |  Transfer  |Save| Px |  Viewport | Format |
      |  Encoding  |Data|Den |   Class   |        |
      +----+----+----+----+----+----+----+----+

Field Definitions

BitsFieldWidthValues
0-1Image Format200 Original, 01 WebP, 10 AVIF, 11 SVG
2-3Viewport Class200 Mobile, 01 Tablet, 10 Desktop
4Pixel Density10 1x, 1 2x+ (Retina)
5Save-Data10 off, 1 on
6-7Transfer Encoding200 Identity, 01 Gzip, 10 Brotli, 11 Reserved

Common Mask Values

MaskHexDescription
0x080x08Default: Desktop, Identity, Original format, 1x, no Save-Data
0x090x09Desktop, Identity, WebP
0x0A0x0ADesktop, Identity, AVIF
0x190x19Desktop, Identity, WebP, 2x
0x290x29Desktop, Identity, WebP, Save-Data
0x490x49Desktop, Gzip, WebP
0x010x01Mobile, Identity, WebP
0x050x05Tablet, Identity, WebP

Sentinel Values

These alternate IDs are reserved for special purposes and are never produced by header classification:

Alternate IDConstantPurpose
0x0CPS_SENTINEL_ORIGINALDurable original content, as the origin sent it
0x1CPS_SENTINEL_EARLY_HINTSStores preload hints for 103 Early Hints
0x2CPS_SENTINEL_WARMUPTriggers full variant matrix generation
0x3CPS_SENTINEL_CONTENT_HASHContent hash for change detection
0x4CPS_SENTINEL_SUBRESOURCESubresource metadata
0x5CPS_SENTINEL_BROWSER_PROFILEBrowser analysis profile data
0x6CPS_SENTINEL_HEADERS_SIDECARVerbatim request-independent response headers

PS_SENTINEL_ORIGINAL and PS_SENTINEL_HEADERS_SIDECAR are written with ps_cache_write_original() and ps_cache_write_headers_sidecar(), not with ps_cache_write_sentinel(), and neither is ever returned by selection — read them by id when you want them.

Cache Key Format

Cache entries are keyed by SHA-256(URL, hostname) using Cyclone’s native key system. Multiple variants of the same URL are stored as alternates within this key, each identified by an AlternateId (the low 8 bits of the capability mask). Per-alternate metadata carries the full 32-bit mask and content type.

Lookups use PageSpeedSelector for best-fit alternate selection in a single pass over all stored alternates (O(alternates), not O(fallback_masks)).

Variant Fallback

When no exact match exists among the stored alternates, the PageSpeedSelector scores alternates by mask similarity and returns the closest match. The original content (stored at the default mask 0x08) always serves as a fallback, so a client always receives a response — either the optimal variant, a close variant, or the original.

C API

ModPageSpeed 2.0 exposes a C API (lib/pagespeed/pagespeed.h) for embedding the cache, HTML processor, CSS minifier, and capability mask logic into custom applications. All functions use the ps_ prefix. The API uses opaque handle types and C linkage, so it can be called from any language with a C FFI.

Handle Types

TypeDescription
ps_cache_tCache volume handle (opaque)
ps_read_result_tResult from a cache read (opaque)
ps_write_handle_tStreaming write handle (opaque)
ps_html_result_tResult from ps_html_process() (opaque)
ps_scan_result_tResult from ps_html_scan() (opaque)
ps_critical_css_result_tResult from critical CSS extraction
ps_html_transform_tHTML transform handle (opaque)

Error Handling

All fallible functions return ps_error_t (marked PS_NODISCARD):

ConstantValueDescription
PS_OK0Success
PS_ERR_NOT_FOUND1Cache key or alternate not found
PS_ERR_IO2I/O error
PS_ERR_CORRUPTED3Data corruption detected
PS_ERR_NO_SPACE4Cache volume full
PS_ERR_INVALID_ARG5Invalid argument
PS_ERR_BUSY6Resource busy
PS_ERR_CLOSED7Handle already closed
PS_ERR_TOO_MANY_ALTERNATES8Alternate limit exceeded
PS_ERR_EXISTS9Entry already exists
PS_ERR_NOT_OWNED10Caller does not own the resource
PS_ERR_VERSION_MISMATCH11Incompatible cache format version
PS_ERR_INTERNAL99Internal error
ps_error_t err = ps_cache_open(&config, &cache);
if (err != PS_OK) {
    fprintf(stderr, "%s: %s\n", ps_error_name(err), ps_strerror(err));
    // ps_last_error_message() returns additional detail (thread-local)
}

Error utility functions:

FunctionSignatureDescription
ps_error_name()const char* ps_error_name(ps_error_t err)Returns the error constant name (e.g., "PS_ERR_IO")
ps_strerror()const char* ps_strerror(ps_error_t err)Returns a human-readable error description
ps_last_error_message()const char* ps_last_error_message(void)Returns thread-local detail message from the last failed call

Content Type Constants

ConstantValueMIME type
PS_CONTENT_HTML0text/html
PS_CONTENT_CSS1text/css
PS_CONTENT_JS2application/javascript
PS_CONTENT_IMAGE3image/*
PS_CONTENT_OTHER4(other)

Use ps_classify_content_type(header) to map a Content-Type header string to a ps_content_type_t, and ps_content_type_mime(type) for the reverse.

Sentinel Constants

ConstantValuePurpose
PS_SENTINEL_ORIGINAL0x0CDurable original content
PS_SENTINEL_EARLY_HINTS0x1CPreload hints for 103 Early Hints
PS_SENTINEL_WARMUP0x2CFull variant matrix generation
PS_SENTINEL_CONTENT_HASH0x3CContent hash for change detection
PS_SENTINEL_SUBRESOURCE0x4CSubresource metadata
PS_SENTINEL_BROWSER_PROFILE0x5CBrowser analysis profile data
PS_SENTINEL_HEADERS_SIDECAR0x6CVerbatim request-independent headers

Cache Functions

Opening and closing:

ps_cache_config_t config;
ps_cache_config_init(&config);            // zero-fills and sets struct_size
config.volume_path = "/data/cache.vol";
config.volume_size = 1ULL << 30;          // 1 GB
config.enable_checksum = 1;
config.ram_cache_size = 64 * 1024 * 1024; // 64 MB RAM cache

ps_cache_t* cache = NULL;
ps_error_t err = ps_cache_open(&config, &cache);
// ...
ps_cache_close(cache);

Reading (zero-copy via mmap):

ps_read_result_t* result = NULL;
ps_error_t err = ps_cache_read_best(cache, url, hostname, mask, &result);
if (err == PS_OK) {
    const uint8_t* data;
    size_t len;
    err = ps_read_content(result, &data, &len);  // pointer into mmap'd region
    uint32_t stored_mask = ps_read_mask(result);
    ps_content_type_t ct = ps_read_content_type(result);
    uint8_t flags = ps_read_flags(result);
    int ram_hit = ps_read_is_ram_hit(result);
    ps_read_free(result);
}

Other read functions:

FunctionDescription
ps_cache_read_best()Best-fit alternate for a capability mask
ps_cache_read_alternate()Read a specific alternate by ID
ps_cache_read_early_hints()Read the Early Hints sentinel
ps_read_content()Get pointer + length from a read result
ps_read_copy()Copy content into a caller-owned buffer
ps_read_mask()Get the stored capability mask
ps_read_content_type()Get the content type
ps_read_origin_content_type()Get the original Content-Type string
ps_read_flags()Get the flags byte (the PS_FLAG_* bits)
ps_read_is_ram_hit()Whether the result came from RAM cache
ps_read_free()Free a read result

Stored origin cache state. ps_read_cache_inserted_at() (when the entry became fresh, in Unix seconds) has always been available; the rest were added at PS_API 1.2. Together they are what the RFC 9111 helpers below take as input:

FunctionDescription
ps_read_origin_max_age()Origin max-age, in seconds
ps_read_origin_s_maxage()Origin s-maxage, in seconds
ps_read_origin_cc_flags()Origin Cache-Control directives (PS_CC_ORIGIN_*)
ps_read_origin_last_modified()Origin Last-Modified as a Unix timestamp
ps_read_origin_etag()Origin ETag, verbatim (quotes and W/ included)

All return 0 / NULL when the field was never recorded.

Writing (streaming):

ps_write_params_t params;
/* The _auto macro takes the size from your own type, so it stays correct when
 * the struct grows. Do not use ps_write_params_init: it initializes only the
 * PS_API 1.1 prefix, and the origin-state fields below are then silently
 * dropped at the write. */
ps_write_params_init_auto(&params);
params.alternate_id = mask & 0xFF;
params.content_length = total_len;
params.full_mask = mask;
params.content_type = PS_CONTENT_CSS;
params.origin_ct = "text/css; charset=utf-8";

/* Origin state (optional, PS_API 1.2). cache_inserted_at must be the time the
 * ORIGIN generated the response: if it reached you through another cache
 * carrying `Age: N`, it was already N seconds old, and recording your own
 * clock instead grants it N extra seconds of apparent freshness. */
params.cache_inserted_at = ps_age_adjusted_insert_time(time(NULL), inbound_age);
params.origin_max_age = 600;
params.origin_cc_flags = PS_CC_ORIGIN_HEADER_PRESENT | PS_CC_ORIGIN_PUBLIC;
params.origin_etag = "\"abc123\"";

ps_write_handle_t* wh = NULL;
ps_error_t err = ps_cache_write_begin(cache, url, hostname, scheme, &params, &wh);
if (err == PS_OK) {
    err = ps_write_data(wh, chunk1, chunk1_len);
    err = ps_write_data(wh, chunk2, chunk2_len);
    err = ps_write_close(wh);  // commits to cache
}
// On error: ps_write_abort(wh) discards the write

Write a sentinel entry (e.g., Early Hints):

ps_write_handle_t* wh = NULL;
ps_cache_write_sentinel(cache, url, hostname,
                        PS_SENTINEL_EARLY_HINTS, hints_len, &wh);
ps_write_data(wh, hints_data, hints_len);
ps_write_close(wh);

Re-recording a sentinel replaces it: with one writer per URL the previous entry under that id is removed before the new one is written, and a read always returns the most recent entry. Concurrent writers for the same URL and id can leave superseded entries behind, bounded by the storage layer’s chain ceiling — a later storage version makes replacement unconditional with no change on your side. Because the write is streaming, the removal happens when you call ps_cache_write_sentinel and the replacement exists only when you close the handle: an abandoned write, or one the storage layer refuses at close, leaves the URL with no entry under that id until the next store. That reads as a miss — never wrong bytes.

Store the durable original of a resource (PS_API 1.3) — the bytes the origin sent, kept so optimized variants can be rebuilt without re-fetching. Same ps_write_params_t, and the origin state you supply is what decides how long the entry may be used:

ps_write_params_t params;
ps_write_params_init_auto(&params);
params.content_length = body_len;
params.content_type = PS_CONTENT_IMAGE;
params.origin_ct = "image/jpeg";
params.cache_inserted_at = ps_age_adjusted_insert_time(time(NULL), inbound_age);
params.origin_max_age = 600;
params.origin_cc_flags = PS_CC_ORIGIN_HEADER_PRESENT | PS_CC_ORIGIN_PUBLIC;

ps_write_handle_t* wh = NULL;
if (ps_cache_write_original(cache, url, hostname, scheme, &params, &wh) == PS_OK) {
    ps_write_data(wh, body, body_len);
    ps_write_close(wh);
}
/* Read it back deliberately, by id — never through selection: */
ps_read_result_t* r = NULL;
ps_cache_read_alternate(cache, url, hostname, scheme, PS_SENTINEL_ORIGINAL, &r);

Responses above 16 MB are not kept as durable originals — above that size the copy costs more than the rebuild it saves, and the limit is fixed in the build rather than configurable. Declaring more returns PS_ERR_NO_SPACE; writing more than you declared fails the ps_write_data call that crosses the limit and abandons the entry, after which ps_write_close returns PS_ERR_CLOSED because there is nothing left to commit. Nothing partial is stored, and nothing else about the response is affected.

Check ps_last_error_message() on PS_ERR_NO_SPACE: the same code means “the cache volume is full”, which is transient and worth retrying, and “this response is over the original-size limit”, which is permanent for that response. The message names the limit in the second case.

Re-recording an original replaces it: the previous one is removed before the new one is written. That holds for a single writer per URL — a read always returns the most recent original, and concurrent writers for the same URL can leave superseded copies behind (bounded by the storage layer’s chain ceiling, but each one holds a whole response body, so serialise them if you write from several processes). A later storage version makes replacement unconditional with no change on your side.

Because the write is streaming, the removal happens when you call ps_cache_write_original and the replacement exists only when you close the handle: for the duration of your stream the URL has no stored original, and an abandoned stream, one whose close the storage layer refuses, or an over-size stream leaves none. That reads as a miss — a re-fetch, never wrong bytes — but if keeping a stale original matters more than the gap, read for one before you start.

One constraint to design around before writing originals in production: an original you store is not fenced against a concurrent purge of the same URL. The engine’s purge fencing is internal to the optimizer process and cannot see your write, so a store that commits after a purge has enumerated the URL survives with its family gone. See the header notes on ps_cache_write_original.

Response-header sidecar:

A cache entry reproduces the headers the cache itself needs — Cache-Control, Content-Type, Content-Length, ETag, Last-Modified, Expires. The sidecar is where the rest of a response’s request-independent headers are kept, verbatim, so a response carrying (say) a Content-Security-Policy can be served from cache with its headers intact instead of being left alone.

const char* names[]  = { "Cache-Control", "Content-Security-Policy", "Vary" };
const char* values[] = { "max-age=600",   "default-src 'self'",      "Accept-Encoding" };

int verdict = 0, stored = 0;
ps_cache_write_headers_sidecar(cache, url, hostname, scheme,
                               names, values, 3, &verdict, &stored);
/* Read it back by id; the blob starts with a payload format version byte. */
ps_read_result_t* r = NULL;
ps_cache_read_alternate(cache, url, hostname, scheme,
                        PS_SENTINEL_HEADERS_SIDECAR, &r);

Pass the response’s complete header set, not a curated one: this call is the admission gate. The headers it keeps are Content-Security-Policy, Access-Control-Allow-Origin, X-Content-Type-Options, Referrer-Policy, the Cross-Origin-* family, Permissions-Policy and the origin’s own Vary string. The list is closed and not configurable — a header whose value depends on the request must never be replayed to the next client.

A refusal is not an error. The call returns PS_OK with *out_stored 0 and one of three verdicts: PS_SIDECAR_SWAP_ELIGIBLE (nothing outside the reproducible set — it may have stored a block, or had nothing to store), PS_SIDECAR_FALL_THROUGH (something cannot be reproduced; serve the response plain), or PS_SIDECAR_NEVER_OPTIMIZED (a Content-Security-Policy carrying a nonce, which is never stored because replaying a nonce defeats the policy). ps_headers_sidecar_classify() answers the same question with no cache involved.

Record the answer on the entry as well as acting on it: on a PS_SIDECAR_FALL_THROUGH or PS_SIDECAR_NEVER_OPTIMIZED verdict, set PS_FLAG_ORIGIN_HEADERS_NOT_REPRODUCIBLE (PS_API 1.8) in params.flags on the write. (Those two verdicts specifically — the classifier also returns -1 for a malformed argument list, which says nothing about the response.) The origin’s headers are gone by serve time, so an entry with no marker is indistinguishable from one whose headers were all reproducible: recording it at write time is the only way a serve path can later tell the two apart and fall through to the plain response instead of answering with headers it knows are incomplete.

Read it back off the entry you stamped, by id. The bit lives on that one entry and nowhere else:

  • A durable original is never returned by ps_cache_read_best() — that class is excluded from selection by design. Ask for it by id instead: ps_cache_read_alternate(cache, url, hostname, scheme, PS_SENTINEL_ORIGINAL, &r), then ps_read_flags(r). Looking for the marker on a best-fit result gets you an unmarked variant and a false all-clear.
  • Variants the optimizer produces do not carry it. Every optimizer write path composes the flags byte of what it stores, and none of them forwards this bit — so a derived variant always reads back with this bit clear.
  • A marker stamped on an ordinary entry through ps_cache_write_begin() lives at that alternate id, including the default-mask id the optimizer’s own output uses — so it is visible on hits until optimization lands and gone afterwards, when the optimizer replaces that entry with one whose flags it composed itself.

The flag is advisory and gates nothing: a marked entry is stored, scored, optimized and expired exactly as an unmarked one. Wherever a marked entry is actually served it does participate in the weak ETag of a hit, along with the rest of the flags byte, so a marked entry does not revalidate against an unmarked one — the two are not served the same way, so they are not the same representation.

A read always returns the most recent block. With a single writer per URL a later store replaces the previous block; with concurrent writers for the same URL, superseded blocks can accumulate (bounded by the storage layer’s chain ceiling) — serialise the writers if you store this class from several processes. Between the removal and the commit there is a window in which a reader finds no block, so treat a missing block as “this response is not optimizable”, never as “optimized, with no headers”. The same concurrent-purge constraint as ps_cache_write_original applies.

Cache policy (RFC 9111):

Pure functions — no cache handle, no I/O — so a front end decides reuse, storability and the emitted Cache-Control with the engine’s own answers instead of a second implementation of RFC 9111 that disagrees in the cases nobody tests.

FunctionDescription
ps_evaluate_freshness()Fresh / serve-no-cache / revalidate, plus age and remaining TTL
ps_freshness_config_init_sized()Default caps and per-content-type lifetimes (use ps_freshness_config_init_auto)
ps_build_cache_control()Assemble the Cache-Control value to send with a hit
ps_vary_uncacheable()Whether a response’s Vary forbids storing it at all
ps_vary_varies_accept()Whether the origin negotiates on Accept itself, so the stored entry must carry PS_FLAG_ORIGIN_VARIES_ACCEPT
ps_parse_cache_control()Derive the PS_CC_ORIGIN_* flags from a Cache-Control header line

ps_parse_cache_control() accumulates: call it once per Cache-Control header line, in order, against the same output — a response may carry several, and RFC 9111 treats them as one list.

Management:

FunctionDescription
ps_cache_alternate_exists()Check if a specific alternate exists
ps_cache_remove()Delete all alternates for a URL
ps_cache_list_alternates()List all alternates (returns ps_alternate_info_t array)
ps_alternates_free()Free the alternate list
ps_cache_stats()Get cache statistics (ps_cache_stats_t); caller sets struct_size first.

Version

FunctionSignatureDescription
ps_version_major()int ps_version_major(void)Major version number
ps_version_minor()int ps_version_minor(void)Minor version number
ps_version_patch()int ps_version_patch(void)Patch version number

Capability Mask Helpers

// Classify request headers into a 32-bit capability mask
uint32_t mask = ps_classify(accept_header, user_agent, save_data, accept_encoding);

// Adjust viewport from a pixel width
mask = ps_mask_set_viewport_from_width(mask, 375);  // → Mobile

// Score how well a stored alternate matches a client mask
int score = ps_score_alternate(client_mask, stored_mask);

// Normalize a hostname for cache key computation
char normalized[256];
int len = ps_normalize_hostname("WWW.Example.COM:443", normalized, sizeof(normalized));
// normalized = "example.com", len = 11
FunctionSignatureDescription
ps_normalize_hostname()int ps_normalize_hostname(const char* hostname, char* out_buf, size_t buf_size)Lowercases hostname, strips www. prefix and port suffix. Returns length of normalized string, or -1 on error.

HTML Processing

Single-call HTML processing with cache-backed resource lookup:

ps_html_config_t config;
ps_html_config_init(&config);                    // PS_API 1.8 legacy: writes 128-byte prefix
// Or, for callers that may meet a newer library:
ps_html_config_init_sized(&config, sizeof(config));  // PS_API 1.9
config.enable_critical_css = 1;
config.enable_lazy_load = 1;
config.enable_lcp_preload = 1;
config.viewport = PS_VIEWPORT_DESKTOP;

ps_html_result_t* result = NULL;
ps_error_t err = ps_html_process(html, html_len, url, hostname,
                                  &config, cache, &result);
if (err == PS_OK) {
    size_t out_len;
    const char* output = ps_html_result_output(result, &out_len);
    int modified = ps_html_result_modified(result);
    int needs_reval = ps_html_result_needs_revalidation(result);

    size_t hints_len;
    const char* hints = ps_html_result_early_hints(result, &hints_len);

    ps_html_result_free(result);
}

For lower-level control, use the scan + transform pipeline:

// Step 1: Scan HTML to extract structure
ps_scan_result_t* scan = NULL;
ps_html_scan(html, html_len, url, &scan);

// Step 2: Extract critical CSS using scan results
ps_critical_css_config_t css_config;
ps_critical_css_config_init(&css_config);                    // PS_API 1.8 legacy: writes 88-byte prefix
// Or, for callers that may meet a newer library:
ps_critical_css_config_init_sized(&css_config, sizeof(css_config));  // PS_API 1.9
ps_critical_css_result_t* crit = NULL;
ps_css_extract_critical(scan, css, css_len, &css_config, &crit);
size_t crit_len;
const char* critical_css = ps_critical_css_output(crit, &crit_len);

// Step 3: Transform HTML with critical CSS injected
ps_html_transform_t* xform = NULL;
ps_html_transform_create(scan, &html_config, critical_css, crit_len,
                          cache, hostname, NULL, 0, &xform);
char* out_html = NULL;
size_t out_len;
ps_html_transform_run(xform, html, html_len, url, &out_html, &out_len);

ps_free(out_html);
ps_html_transform_free(xform);
ps_critical_css_result_free(crit);
ps_scan_result_free(scan);

Scanner inspection:

FunctionSignatureDescription
ps_scan_element_count()size_t ps_scan_element_count(const ps_scan_result_t* result)Number of above-the-fold elements found
ps_scan_element()ps_error_t ps_scan_element(const ps_scan_result_t* result, size_t index, const char** out_tag, const char** out_id, int* out_depth, int* out_element_index)Get tag, id, depth, and DOM index of element at index
ps_scan_element_classes()size_t ps_scan_element_classes(const ps_scan_result_t* result, size_t index, const char*** out_classes)Get CSS classes for element at index; returns class count
ps_scan_stylesheet_count()size_t ps_scan_stylesheet_count(const ps_scan_result_t* result)Number of external stylesheets found
ps_scan_stylesheet()ps_error_t ps_scan_stylesheet(const ps_scan_result_t* result, size_t index, const char** out_href, const char** out_media)Get href and media attribute of stylesheet at index
ps_scan_inline_css()const char* ps_scan_inline_css(const ps_scan_result_t* result, size_t* out_len)Concatenated inline <style> content
ps_scan_lcp_candidate()const char* ps_scan_lcp_candidate(const ps_scan_result_t* result, const char** out_srcset, const char** out_sizes, int* out_element_index)LCP image candidate (returns src, outputs srcset, sizes, element index)
ps_scan_origin_count()size_t ps_scan_origin_count(const ps_scan_result_t* result)Number of third-party origins detected
ps_scan_origin()const char* ps_scan_origin(const ps_scan_result_t* result, size_t index)Third-party origin URL at index
ps_scan_result_free()void ps_scan_result_free(ps_scan_result_t* result)Free a scan result

HTML result inspection:

FunctionSignatureDescription
ps_html_result_has_critical_css()int ps_html_result_has_critical_css(const ps_html_result_t* result)Whether the result contains inlined critical CSS
ps_html_transform_modified()int ps_html_transform_modified(const ps_html_transform_t* transform)Whether the transform changed the HTML

Critical CSS inspection:

FunctionSignatureDescription
ps_critical_css_output()const char* ps_critical_css_output(const ps_critical_css_result_t* result, size_t* out_len)Extracted critical CSS string
ps_critical_css_stats()void ps_critical_css_stats(const ps_critical_css_result_t* result, int* out_total_rules, int* out_critical_rules)Total and critical rule counts
ps_critical_css_result_free()void ps_critical_css_result_free(ps_critical_css_result_t* result)Free a critical CSS result

CSS Processing

// Minify CSS (single call, no minifier object)
char* out_css = NULL;
size_t out_len;
ps_error_t err = ps_css_minify(css, css_len, &out_css, &out_len);
if (err == PS_OK) {
    // use out_css / out_len
    ps_free(out_css);
}

// Validate CSS syntax
ps_error_t err = ps_css_validate(css, css_len);

// Flatten @import directives (requires a lookup callback)
char* flat_css = NULL;
size_t flat_len;
int resolved, unresolved;
ps_css_flatten_imports(css, css_len, css_url, my_lookup_fn, user_data,
                       /*max_depth=*/3, &flat_css, &flat_len,
                       &resolved, &unresolved);
ps_free(flat_css);

Configuration Structs

ps_cache_stats_t — returned by ps_cache_stats():

FieldTypeDescription
struct_sizesize_tSize of this struct (for ABI compat). Caller sets to sizeof as its build sees it before the call. Smaller than sizeof(size_t), the call fails with PS_ERR_INVALID_ARG; otherwise the library fills the prefix and reports bytes written.
ram_cache_hitsuint64_tRAM cache hit count
ram_cache_missesuint64_tRAM cache miss count
disk_cache_hitsuint64_tDisk cache hit count
disk_cache_missesuint64_tDisk cache miss count
bytes_readuint64_tTotal bytes read from cache
bytes_writtenuint64_tTotal bytes written to cache
evictionsuint64_tTotal evictions
current_entriesuint64_tCurrent number of cache entries
current_size_bytesuint64_tCurrent cache size in bytes
volume_capacity_bytesuint64_tTotal volume capacity in bytes
ram_cache_bytesuint64_tCurrent RAM cache usage in bytes
total_hitsuint64_tTotal hits (RAM + disk)
total_missesuint64_tTotal misses (RAM + disk)

ps_css_lookup_fn — callback for ps_css_flatten_imports():

typedef const char* (*ps_css_lookup_fn)(const char* url, size_t* out_len, void* user_data);

Called for each @import URL encountered during flattening. Return the CSS content and set *out_len to its length. Return NULL if the URL cannot be resolved (counted as unresolved). The user_data pointer is passed through from ps_css_flatten_imports().

Worker Notification

// Send a fire-and-forget notification to the worker over a Unix socket
ps_error_t err = ps_notify_worker("/var/lib/pagespeed/pagespeed.sock",
                                   url, hostname, "https",
                                   PS_CONTENT_IMAGE, mask);

With a per-request options context

ps_notify_worker_ex (PS_API 1.5) takes everything above plus the agent-request bit and the options context: the configuration a request resolved to, as a canonical payload, together with a signature over it.

The signature is checked byte for byte against the payload it arrives with, on every notification that carries one — that check is what catches two implementations of the format drifting apart. A context that passes is accepted whatever it names, and the work is processed and stored under the default context: optimized output is a function of the resource, of the request (client capabilities and content type, plus whether it was an entitled agent request — the request’s own declared intent, gated by an entitlement the optimizer itself publishes), and of the optimizer’s own configuration, so two requests that resolved to two different configurations cannot produce two different optimized artifacts. Keep supplying the context you resolve — it is what the drift check runs on.

ps_notify_params_t params;
ps_notify_params_init_auto(&params);          /* not ps_notify_params_init_sized(..., 0) */
params.url           = url;
params.hostname      = hostname;
params.scheme        = "https";
params.content_type  = PS_CONTENT_IMAGE;
params.mask          = mask;

/* Optional. Supply BOTH or NEITHER. */
char signature[PS_OPTION_CONTEXT_SIGNATURE_CHARS + 1];
ps_error_t err = ps_option_context_signature(payload, payload_len,
                                             signature, sizeof(signature));
if (err == PS_OK) {
  params.option_context        = payload;
  params.option_context_length = payload_len;
  params.option_signature      = signature;
}

err = ps_notify_worker_ex("/var/lib/pagespeed/pagespeed.sock", &params);

Rules the library enforces, each returning PS_ERR_INVALID_ARG:

rulewhy
payload and signature are supplied together, or neithera signature can only be checked against the payload it came with, and that check is the point of carrying it
the signature must be the signature of that payload — it is checked, not trusteda signature computed over something else means the two sides no longer agree on how a configuration is rendered; the receiver refuses such a notification too
the payload must not exceed PS_MAX_OPTION_CONTEXT_BYTESnothing truncates a payload: half a payload signs as a context that does not exist
the payload must open with a format version this library knowsa payload read by the wrong parser produces a signature that means something else

Omitting the options context entirely is the pre-existing behaviour in every respect, and ps_notify_worker remains available and unchanged.

Use ps_option_context_signature rather than reimplementing it. Every consumer that declares an options context has to produce byte-identical signatures for byte-identical payloads indefinitely; a reimplementation that drifts by one byte has every notification it sends refused, because the receiver re-derives the signature from the payload rather than trusting the one on the wire.

Memory Management

Buffers allocated by the API (ps_css_minify, ps_html_transform_run) must be freed with ps_free(). Opaque result types have dedicated free functions (ps_read_free, ps_html_result_free, etc.).

Thread Safety

Cache operations are thread-safe (the underlying Cyclone cache uses lock-free reads and per-bucket write locks). ps_html_result_t, ps_scan_result_t, ps_html_transform_t, and write handles are not thread-safe — use one per thread.

Next Steps