Skip to content

Integration Guide

This document explains how to integrate a new integration module with the Trusted Server runtime. The workflow mirrors the built-in testlight sample in crates/trusted-server-core/src/integrations/testlight.rs.

Architecture Overview

ComponentPurpose
crates/trusted-server-core/src/integrations/registry.rsDefines the IntegrationProxy, IntegrationAttributeRewriter, IntegrationScriptRewriter, and IntegrationHeadInjector traits and hosts the IntegrationRegistry, which drives proxy routing, HTML/text rewrites, and head injection.
Settings::integrations (crates/trusted-server-core/src/settings.rs)Free-form JSON blob keyed by integration ID. Use IntegrationSettings::insert_config to seed configs; each module deserializes and validates (validator::Validate) its own config and exposes an enabled flag so the core settings schema stays stable.
Fastly entrypoint (crates/trusted-server-adapter-fastly/src/main.rs)Instantiates the registry once per request, routes /integrations/<id>/… requests to the appropriate proxy, and passes the registry to the publisher origin proxy so HTML rewriting remains integration-aware.
html_processor.rsApplies first-party URL rewrites, injects the Trusted Server JS shim, and lets integrations override attribute values (for example to swap script URLs).

Step-by-Step Integration

1. Define Integration Configuration

Add a trusted-server.toml block and any environment overrides under TRUSTED_SERVER__INTEGRATIONS__<ID>__*. Configuration values are exposed to your module via Settings::integration_config(<id>).

toml
[integrations.my_integration]
endpoint = "https://example.com/api"
timeout_ms = 1000
rewrite_scripts = true

2. Create the Integration Module

Add a module under crates/trusted-server-core/src/integrations/<id>/mod.rs (see crates/trusted-server-core/src/integrations/testlight.rs for reference) and expose it in crates/trusted-server-core/src/integrations/mod.rs.

Key pieces:

rust
#[derive(Deserialize, Validate)]
struct MyIntegrationConfig {
    #[serde(default = "default_enabled")]
    enabled: bool,
    // …
}

impl IntegrationConfig for MyIntegrationConfig {
    fn is_enabled(&self) -> bool { self.enabled }
}

pub struct MyIntegration {
    config: MyIntegrationConfig,
}

pub fn build(settings: &Settings) -> Option<Arc<MyIntegration>> {
    let config = settings
        .integration_config::<MyIntegrationConfig>("my_integration")
        .ok()
        .flatten()?;
    Some(Arc::new(MyIntegration { config }))
}

// Tests or scaffolding code can seed configs without hand-writing JSON:
settings
    .integrations
    .insert_config(
        "my_integration",
        &serde_json::json!({
            "enabled": true,
            "endpoint": "https://example.com/api"
        }),
    )?;

Settings::integration_config::<T> automatically deserializes the raw JSON blob, runs validator on the type, and drops configs whose is_enabled returns false. Always derive/implement Validate for schema enforcement and implement IntegrationConfig (typically wrapping a #[serde(default)] enabled flag) so operators can toggle integrations without code changes.

3. Return an IntegrationRegistration

Each integration registers itself via a register function that returns an IntegrationRegistration. This object describes which HTTP proxies and HTML rewrites the integration exposes:

rust
pub fn register(settings: &Settings) -> Option<IntegrationRegistration> {
    let integration = build(settings)?;
    Some(
        IntegrationRegistration::builder("my_integration")
            .with_proxy(integration.clone())
            .with_attribute_rewriter(integration.clone())
            .with_script_rewriter(integration.clone())
            .with_head_injector(integration)
            .with_asset("my_integration")
            .build(),
    )
}

Any combination of the vectors may be populated. Modules that only need HTML rewrites can skip the proxies field altogether, and vice versa. The registry automatically iterates over the static builder list in crates/trusted-server-core/src/integrations/mod.rs, so adding the new register function is enough to make the integration discoverable.

4. Implement IntegrationProxy for Endpoints

Implement the trait from registry.rs when your integration needs its own HTTP entrypoint:

rust
#[async_trait(?Send)]
impl IntegrationProxy for MyIntegration {
    fn integration_name(&self) -> &'static str {
        "my_integration"
    }

    fn routes(&self) -> Vec<IntegrationEndpoint> {
        vec![
            self.post("/auction"),
            self.get("/status"),
        ]
    }

    async fn handle(
        &self,
        settings: &Settings,
        req: Request,
    ) -> Result<Response, Report<TrustedServerError>> {
        // Parse/generate synthetic IDs, forward upstream, and return the response.
    }
}

Route Helpers

Use the provided helper methods to automatically namespace your routes under /integrations/{integration_name()}/. Available helpers: get(), post(), put(), delete(), and patch(). This lets you define routes with just their relative paths (e.g., self.post("/auction") becomes "/integrations/my_integration/auction").

Routes are matched verbatim in crates/trusted-server-adapter-fastly/src/main.rs, so stick to stable paths and register whichever HTTP methods you need. New integrations should namespace their routes under /integrations/{INTEGRATION_NAME}/ using the helper methods for consistency, but you can define routes manually if needed (e.g., for backwards compatibility).

The shared context already injects Trusted Server logging, headers, and error handling; the handler only needs to deserialize the request, call the upstream endpoint, and stamp integration-specific headers.

Proxying Upstream Requests

Use the shared helper in crates/trusted-server-core/src/proxy.rs to forward requests so you automatically get the same header copying, redirect handling, HTML/CSS rewrite behavior, and synthetic ID handling the first-party proxy uses:

rust
use crate::proxy::{proxy_request, ProxyRequestConfig};
use fastly::http::{header, HeaderValue};

let payload = serde_json::to_vec(&my_body)?;
let response = proxy_request(
    settings,
    req,
    ProxyRequestConfig::new(&self.config.endpoint)
        .with_body(payload)
        .with_header(header::CONTENT_TYPE, HeaderValue::from_static("application/json"))
        .with_streaming(), // stream passthrough; disable if you need HTML rewrites
)
.await?;

Set forward_synthetic_id to false if the upstream should not receive the caller's synthetic ID (Testlight does this), and disable follow_redirects if you need to surface redirects directly to the caller.

Streaming passthrough example:

rust
let response = proxy_request(
    settings,
    req,
    ProxyRequestConfig::new("https://example.com/pixel")
        .with_streaming() // no HTML/CSS rewrites; preserves origin compression
);

When to Use Streaming

Use streaming when the upstream response is binary or large and you do not need creative rewrites. Keep the default (non-streaming) mode when you want HTML/CSS content rewritten through the existing creative pipeline.

5. Implement HTML Rewrite Hooks (Optional)

If the integration needs to rewrite script/link tags or inject HTML, implement IntegrationAttributeRewriter for attribute mutation and IntegrationScriptRewriter for inline <script> or text content rewrites. Both traits return typed actions (AttributeRewriteAction, ScriptRewriteAction) so you can keep existing markup, swap values, or drop elements entirely.

rust
impl IntegrationAttributeRewriter for MyIntegration {
    fn integration_id(&self) -> &'static str { "my_integration" }

    fn handles_attribute(&self, attribute: &str) -> bool {
        attribute == "src" || attribute == "href"
    }

    fn rewrite(
        &self,
        attr_name: &str,
        attr_value: &str,
        ctx: &IntegrationAttributeContext<'_>,
    ) -> AttributeRewriteAction {
        if attr_value.contains("cdn.example.com/legacy.js") {
            // Drop remote script entirely – unified bundle already contains the logic.
            AttributeRewriteAction::remove_element()
        } else if attr_name == "src" {
            AttributeRewriteAction::replace(tsjs::unified_script_src())
        } else {
            AttributeRewriteAction::keep()
        }
    }
}

impl IntegrationScriptRewriter for MyIntegration {
    fn integration_id(&self) -> &'static str { "my_integration" }
    fn selector(&self) -> &'static str { "script#__NEXT_DATA__" }

    fn rewrite(
        &self,
        content: &str,
        ctx: &IntegrationScriptContext<'_>,
    ) -> ScriptRewriteAction {
        if let Some(rewritten) = try_rewrite_next_payload(content) {
            ScriptRewriteAction::replace(rewritten)
        } else {
            ScriptRewriteAction::keep()
        }
    }
}

html_processor.rs calls these hooks after applying the standard origin→first-party rewrite, so you can simply swap URLs, append query parameters, or mutate inline JSON. Use this to point <script> tags at your own tsjs-managed bundle (for example, /static/tsjs=tsjs-testlight.min.js) or to rewrite embedded Next.js payloads.

Removing Elements

Returning AttributeRewriteAction::remove_element() (or ScriptRewriteAction::RemoveNode for inline content) removes the element entirely, so integrations can drop publisher-provided markup when the Trusted Server already injects a safe alternative. Prebid, for example, removes publisher prebid.js tags because it ships its own deferred bundle (tsjs-prebid.min.js).

5b. Implement Head Injection (Optional)

If the integration needs to inject HTML snippets at the start of <head> (for example, configuration scripts or global bootstraps), implement IntegrationHeadInjector. Snippets are prepended into <head> before the TSJS bundle tags, so they run first.

rust
impl IntegrationHeadInjector for MyIntegration {
    fn integration_id(&self) -> &'static str { "my_integration" }

    fn head_inserts(&self, ctx: &IntegrationHtmlContext<'_>) -> Vec<String> {
        vec![format!(
            r#"<script>tsjs.setConfig({{ mode: "my_integration", host: "{}" }});</script>"#,
            ctx.request_host
        )]
    }
}

html_processor.rs calls head_inserts once per HTML response when the <head> element is first encountered. The returned snippets are concatenated before the unified script tag, so ordering between integrations is not guaranteed — keep snippets self-contained.

When to Use Head Injection

Use IntegrationHeadInjector when you need to emit configuration, inline scripts, or <meta> tags that must appear early in <head>. For attribute or script content changes on existing elements, prefer IntegrationAttributeRewriter or IntegrationScriptRewriter instead.

6. Register the Module

Add the module to crates/trusted-server-core/src/integrations/mod.rs's builder list. The registry will call its register function automatically. Once registered:

  • crates/trusted-server-adapter-fastly/src/main.rs automatically exposes the declared route(s).
  • handle_publisher_request receives the same registry so HTML responses get integration shims without further code changes.
  • IntegrationRegistry::registered_integrations() exposes a machine-readable summary of hooks for tests, tooling, or diagnostics.
  • Declared assets are injected automatically into <head>; the runtime emits <script async data-tsjs-integration="<name>"> tags for every bundle discovered through .with_asset(...).

7. Provide Static Assets (If Needed)

Place any integration-specific JavaScript entrypoint under crates/js/lib/src/integrations/ (for example, crates/js/lib/src/integrations/testlight.ts). The shared npm run build script automatically discovers every file in that directory and produces a bundle named tsjs-<entry>.js, which the Rust crate embeds as /static/tsjs=tsjs-<entry>.min.js.

Integrations that ship additional JS (such as Testlight) typically expose a shim_src config and rewrite publisher tags to point at that URL. Others (like Prebid) drop the publisher tag because the server injects its own deferred bundle automatically.

8. Test Locally

  1. Add minimal config (trusted-server.toml + .env.* overrides).
  2. Run cargo fmt --all -- --check and cargo clippy --workspace --all-targets --all-features -- -D warnings.
  3. Execute targeted tests, e.g. cargo test -p trusted-server-core html_processor.
  4. Use fastly compute serve (with Viceroy installed) to hit /integrations/<id>/… and fetch HTML from your origin to confirm rewrites are applied.

Testing Strategy

For unit tests, prefer exposing helper constructors that accept a synthetic shim_src so your tests can point rewriters at a deterministic URL without touching the Tsjs build artifacts.

By following these steps you can ship independent integration modules that plug into the Trusted Server runtime without modifying the Fastly entrypoint or HTML processor each time.

Existing Integrations

Two built-in integrations demonstrate how the framework pieces fit together.

Integrations are loaded in one of two ways:

  • Immediate (default) — concatenated into the main tsjs-unified.min.js bundle, loaded synchronously at <head> start.
  • Deferred — served as a separate <script defer> tag (tsjs-{id}.min.js), loaded after HTML parsing completes. Used for large modules that would otherwise block rendering. Integrations opt in by calling .with_deferred_js() on their registration builder.

Testlight

Loading: Immediate

Purpose: Sample partner stub showing request proxying, attribute rewrites, and asset injection.

Key files:

  • crates/trusted-server-core/src/integrations/testlight.rs - Rust implementation
  • crates/js/lib/src/integrations/testlight.ts - TypeScript shim

Prebid

Loading: Deferred (<script defer>)

Purpose: Production Prebid Server bridge that owns /first-party/ad & /third-party/ad, injects synthetic IDs, rewrites creatives/notification URLs, and removes publisher-supplied Prebid scripts. The NPM-bundled Prebid.js is served as a separate deferred bundle (tsjs-prebid.min.js) to avoid blocking page rendering (168 KB).

Key files:

  • crates/trusted-server-core/src/integrations/prebid.rs - Rust implementation
  • crates/js/lib/src/integrations/prebid/index.ts - TypeScript NPM integration

Prebid Integration Details

Prebid applies the same steps outlined above with a few notable patterns:

1. Typed Configuration

PrebidIntegrationConfig lives alongside the integration module (crates/trusted-server-core/src/integrations/prebid.rs), implements IntegrationConfig + Validate, and exposes an enabled flag so operators can toggle it without code changes. Configuration lives under [integrations.prebid]:

toml
[integrations.prebid]
enabled = true
server_url = "https://prebid.example/openrtb2/auction"
timeout_ms = 1200
bidders = ["equativ", "sampleBidder"]
# script_patterns = ["/static/prebid/*"]

Tests or scaffolding can inject configs by calling settings.integrations.insert_config("prebid", &serde_json::json!({...})), the same helper that other integrations use.

2. Routes Owned by the Integration

IntegrationProxy::routes declares the /integrations/prebid/first-party/ad (GET) and /integrations/prebid/third-party/ad (POST) endpoints. Both handlers share helpers that shape OpenRTB payloads, inject synthetic IDs + geo/request-signing context, forward requests via ensure_backend_from_url, and run the HTML creative rewrites before responding. All routes are properly namespaced under /integrations/prebid/ to follow the integration routing pattern.

3. HTML Rewrites Through the Registry

When the integration is enabled, the IntegrationAttributeRewriter removes any <script src="prebid*.js"> or <link href=…> references that match script_patterns. The Prebid module is loaded as a separate <script defer> tag after the main TSJS bundle, so dropping the publisher assets prevents duplicate downloads. The server-injected config (window.__tsjs_prebid) is an inline script that runs before both bundles, ensuring configuration is available when the deferred Prebid module auto-initializes.

4. TSJS Assets & Testing

The NPM integration lives in crates/js/lib/src/integrations/prebid/index.ts. Tests typically assert that publisher references disappear and the deferred tsjs-prebid.min.js tag is present.

Reusing these patterns makes it straightforward to convert additional legacy flows (for example, Next.js rewrites) into first-class integrations.

Future Improvements

We plan to expand integration capabilities in several areas:

  1. Declarative Routing & Middleware - Richer endpoints (path params, shared middleware, structured context) beyond simple method/path matching.
  2. Granular HTML Hooks - Ordered selectors, head/body injection points, and DOM-aware helpers so multiple integrations can safely collaborate.
  3. Integration Manifest - Schema describing required bundles, routes, config validation, and feature flags to keep registration data-driven.
  4. Shared Request Utilities - Reusable building blocks for synthetic ID injection, consent enforcement, and OpenRTB shaping.
  5. tsjs Tooling - Auto-generated integration bundles, scaffolding for TS shims/tests, and metadata surfaced back to Rust.
  6. Testing & Observability Hooks - Integration-focused mocks, local harnesses, and telemetry emitters for easier validation and monitoring.

Contributions toward these enhancements are welcome.

Next Steps

Released under the Apache License 2.0.