mte-relay-browser
v5.0.0
Published
A browser-focused TypeScript SDK that wraps native `fetch` with transparent MTE Relay transport.
Readme
mte-relay-browser (TypeScript / Browser)
A browser-focused TypeScript SDK that wraps native fetch with transparent MTE Relay transport.
The SDK preserves the caller experience (fetch-compatible request/response), while encoding outbound requests into MTE binary frames and decoding inbound relay frames back into normal HTTP responses.
Release identity
This SDK is 5.0.0, the first stable release for Relay v5, which speaks frame
version 2. Every MTE Relay v5 web package carries the relay's major version:
mte-relay-browser, mte-relay-browser-public-client, and mte-relay-angular.
The temporary v6 branch name did not represent a separate product release.
Published versions through 5.0.0-beta.11 use the earlier protocol, which a v5
relay refuses with 478. Publish this release with npm run publish:latest.
Features
Uploads above 32 MiB are staged as blobs. The client reads and encrypts at most 8 MiB of plaintext per batch, then sends the encoded blob in one HTTP request. This avoids multi-gigabyte contiguous JavaScript buffers and works over HTTP/1.1. The full encoded upload is still staged before sending; browser blob storage, memory, disk space and request timeouts still apply. Smaller bodies retain the byte-buffer path. Automatic retries can replay either representation.
fetch-compatible wrapper API- MTE/MKE frame encode + decode lifecycle
- Lazy per-origin relay sessions (auto auth + pair on first request to each server)
- Multi-pair round-robin state pool with warm baseline (
minPairs, default 5), startup size (initialPairs, default 8), and hard ceiling (maxPairs, default 15) - Early background refill when idle pairs drop to or below
minPairs - Immediate
MteRelayCapacityErrorwhen pool is atmaxPairswith no idle pair - Every caller header is encrypted by default;
unencryptedHeaderssends a chosen subset as ordinary HTTP headers on the relay POST - Relay discovery: the SDK reads the
relayobject from the auth response to fail early on protocol, encode type, or Kyber mismatches, tune keepalive, reject oversized bodies before upload, and warn when a plain header will not reach the origin - Pass through routes advertised by the relay are sent as plain
fetchcalls, no frame - Sticky routing: the SDK echoes the relay's
X-MTE-Relay-Routetoken so a header hashing load balancer can pin a client to one relay pod - Per-pair serialized execution to protect stateful encoder/decoder usage
- Native
text/event-streamresponses stay streaming instead of being buffered - MKE-framed responses, including decoded
text/event-streambodies, are parsed metadata-first and streamed through a browserResponsewith one progressiveStartDecrypt/DecryptChunk/FinishDecryptsession across the body - Explicit one-time initialization via
initMteRelayClient(...) - Relay error handling for HTTP
559–569 - Full re-pair flow for relay HTTP
559–569and local MTE encode/decode failures (drop state, re-authenticate, re-pair, then surface the repaired error) - Periodic keep-alive pings to
/api/mte-keepaliveso idle clients (backgrounded tabs, long-lived single-page apps) do not lose their pair pool to server-side session timeout
Prerequisites
- Node.js 18+
- npm
- Running MTE Relay Server, built against the MTE server library (see below)
- Valid MTE company name and license key
- Browser runtime with
fetch,AbortController, andWebCryptosupport- https://caniuse.com/?search=fetch
- https://caniuse.com/?search=AbortController
- https://caniuse.com/?search=Crypto
MTE library build pairing
The MTE library ships in three builds and they are not interchangeable:
A client build pairs only with a server build. An ANY build pairs only with another ANY build. There is no build that pairs with everything.
This SDK links the client build. The mte peer dependency is the client WASM package, so
there is nothing to choose here. What it means is a requirement on the other end:
| Process | Build |
| --- | --- |
| This SDK, in the browser | client, already fixed by the npm package |
| The relay it talks to | server. In mte-relay, copy mte/mrs-server-lib/ into mte/lib/ |
A relay running the ANY build cannot decode a single request from this SDK. The failure is
immediate and total, not intermittent: mte_status_token_does_not_exist on the first
message of a pair, then mte_status_seq_mismatch on everything after it. If pairing
succeeds and then every request fails to decode, check the relay's library build before
anything else.
Note that the relay repository defaults to the ANY build, because its own test suite runs a
client and a server in one process and only an ANY build pairs with itself. Running a relay
for this SDK means swapping the library and clearing the cgo cache first. The full rule, the
per process table, and a cross build interop check are documented in mte-relay/mte/README.md.
scripts/interop-fixture.mjs in this repository is the browser half of that check. It
passes real ciphertext to and from a relay build without going through the relay protocol,
so it answers "do these two libraries pair" on its own.
Install
npm installBuild
npm run buildnpm run build creates the distributable package without JavaScript source maps and without TypeScript declaration maps.
npm run build:with-mapnpm run build:with-map builds the package with Vite source maps and TypeScript declaration maps.
Scripts
npm run devstarts the Vite development server.npm run buildcreates the production package without JavaScript source maps and without TypeScript declaration maps.npm run build:with-mapcreates the production package with JavaScript source maps and TypeScript declaration maps.npm run publish:beta:dry-runrebuilds the package and previews a beta publish without uploading anything.npm run publish:betaasks for confirmation, rebuilds the package, and publishes the current version with thebetadist-tag.npm run publish:latestasks for confirmation, rebuilds the package, and publishes the current version with the default npm dist-tag.npm run testruns Vitest once.npm run test:watchstarts Vitest in watch mode.npm run e2e:local [relayOrigin]runsscripts/e2e-local-relay.mjsagainst a locally running v5 relay. See below.
Local end to end check
npm run e2e:local exercises the built dist/ (run npm run build first) under Node against a real relay: JSON GET, POST with body and encrypted headers, 204, query string, SSE over MKE, and the metadata size bounds under both encode types. It needs a v5 relay running locally whose DOMAIN_MAP routes "*" to https://jsonplaceholder.eclypses.com, built with the MRS server library (copy mte/mrs-server-lib into mte/lib in the server repo and build with go build -a). The script takes the relay origin as its one argument; the default is http://127.0.0.1:8090. When the relay runs inside WSL and the script runs on Windows, pass http://localhost:8090. Exit code is 0 only when every check passes.
Publishing Beta Releases
Use this workflow when publishing a prerelease to npm:
- Check the version in
package.jsonand confirm it is the beta version you want to publish. - If you need the next beta number, run:
npm version prerelease --preid beta- Rebuild and preview the published package:
npm run publish:beta:dry-run- Publish the beta package:
npm run publish:betaNotes:
npm publish -betais not a valid npm command. Usenpm publish --tag betainstead.npm run publish:betarequires interactive confirmation. Typepublishwhen prompted to continue.npm run buildis the publish-safe build. It disables JavaScript source maps and TypeScript declaration maps.prepackrunsnpm run buildbeforenpm packandnpm publish, so the tarball is rebuilt from a clean, map-freedist.package.jsonpublishes only thedistdirectory via thefilesarray, so raw source files fromsrc/are not included in the npm package.
Local Development (npm link)
Use this when developing this package and consuming it from another local project.
- Build and register global link from this repo:
npm run build
npm link- In another local project, link this package:
npm link mte-relay-browser- Use it normally in code:
import { initMteRelayClient, mteFetch } from "mte-relay-browser";- After changing this package, rebuild here and restart the consumer app if needed:
npm run build- Unlink when done:
# in consumer project
npm unlink mte-relay-browser
# in this package repo (remove global link)
npm unlink -g mte-relay-browserSDK Location
Primary implementation lives in:
src/mte-relay/
Key modules:
client.ts— lifecycle + fetch wrapperpairing.ts— Kyber key exchange + cross-wired pair instantiationprotocol/frameWriter.ts— request frame constructionprotocol/frameReader.ts— response frame parsingtransport.ts— relay POST transport (usingcontent-type=application/octet-stream)headerPolicy.ts—unencryptedHeadersvalidation and the encrypted versus plain header splitdiscovery.ts— parsing and applying the relay's discovery objecterrors.ts— relay error classification and error typeindex.ts— public exports
Configuration
initMteRelayClient(options) accepts:
type InitMteRelayOptions = {
companyName: string;
licenseKey: string;
minPairs?: number; // warm baseline pair count, default 5
initialPairs?: number; // pairs created at session start, default 8
maxPairs?: number; // hard pool ceiling, default 15
defaultEncodeType?: "MTE" | "MKE"; // default "MKE"
sequenceWindow?: number; // default -63, or the relay's value when discovery reports one
timeWindow?: number; // default 1000
httpTimeoutMs?: number; // default 30000
keepAliveIntervalMs?: number; // default 300000 (5 min) or one third of the relay's session timeout; clamped to [60000, 600000]
unencryptedHeaders?: string[]; // default [] (encrypt every header); ["*"] sends every header plain
credentials?: RequestCredentials; // default "include"
warnings?: boolean; // default true; set false to silence SDK console warnings
requestStreaming?: boolean | { minBytes?: number }; // default true; see Streamed request bodies
streamDiagnostics?:
| boolean
| {
maxPreviewBytes?: number; // default 48
onEvent?: (event: StreamDiagnosticEvent) => void;
};
onPoolEvent?: (event: PoolEvent) => void;
};
type MteFetchOptions = {
encodeType?: "MTE" | "MKE";
unencryptedHeaders?: string[]; // replaces the init list for this request only
requestStreaming?: boolean; // false stages this body; true streams it whatever its size
};Notes:
- Requests are routed to relay sessions by request URL origin and effective encode type (for example,
https://relay.example.com/...with the default"MKE"mode uses a different internal session than an explicit per-request"MTE"override). - Request URLs must resolve to a normal HTTP(S) origin. Requests that do not produce one throw an error.
httpTimeoutMsmust be a positive integer.sequenceWindowmust be an integer in the range-65535..65535. When you do not set it and the relay reports its own value through discovery, the SDK adopts the relay's value.timeWindowmust be a non-negative integer.companyNameandlicenseKeyare required for license bootstrap.minPairscontrols the warm baseline pool size. Background refill fires when idle pairs drop to or below this value. Must be a positive integer; default 5.initialPairsis how many pairs are created when a session starts. Must be an integer betweenminPairsandmaxPairs; default 8.maxPairsis the hard ceiling on the pool. When the pool is at this count and no pair is idle,mteFetchthrowsMteRelayCapacityErrorimmediately. Must be an integer ≥minPairs; default 15. If the relay advertises a lower per client cap through discovery, the SDK clamps all three pool values to it and warns once.- Framed MKE responses stream without special configuration when the effective encode type is
"MKE", which is the default. - Requests that explicitly set
encodeType: "MTE"throw a protocol error if the relay returns an MKE-framed response. keepAliveIntervalMscontrols how often the SDK pings/api/mte-keepaliveto keep its pair pool from being reaped by the relay server's session timeout. Each relay session runs its own timer once it has paired. In browsers, an immediate ping also fires ondocument.visibilitychange → visibleto recover from background-tab throttling. Must be an integer in[60000, 600000]ms (1–10 minutes); default 300000 (5 minutes). When you do not set it and the relay reports its session timeout through discovery, the SDK uses one third of that timeout, clamped to the same range. When you do set it above one third of the relay's timeout, the SDK warns once.unencryptedHeaderslists caller header names that travel as ordinary HTTP headers on the relay POST instead of inside the encrypted metadata. Default is an empty list, so every header is encrypted. See Header encryption below.credentialsis the fetch credentials mode the SDK applies to every request it makes to the relay: auth, pair, keepalive, and framed requests. Default"include". Must be"include","same-origin", or"omit".warningsturns the SDK's once per session console warnings on or off. Defaulttrue.streamDiagnosticsis optional and disabled by default. Set it totruefor bounded console diagnostics, or provide{ maxPreviewBytes, onEvent }to capture native SSE chunks and MKE decrypt chunk previews programmatically. Everyrequest-frameevent carriesdetails.transport,"stream"or"buffer", so a caller can see which path a body took.onPoolEventis optional. When provided, it is called for pool lifecycle events:refill-triggered,pair-appended,pair-removed,pair-replaced,capacity-exhausted,stream-reserved, andstream-released.requestStreamingturns streamed request bodies on or off. Defaulttrue. See Streamed request bodies below.
Streamed request bodies
A request body can travel in two ways. The staged path frames the whole body before the POST: every DATA frame is encoded, the frames are joined into one buffer (or, above 32 MB, into blobs), and the POST carries a Content-Length. The streamed path hands fetch a ReadableStream with duplex: "half": the REQUEST frame goes out on the first pull and one DATA frame is cut, encoded, and framed on each pull after that. Nothing is staged, a body of any size costs a few chunks of memory, and the relay starts reading the first frames while the last are still being encoded.
Streaming is a capability, not a request. The SDK uses it only when three things hold, and falls back to the staged path silently otherwise:
- The runtime can send a
ReadableStreambody. Chromium 105 and later (Chrome, Edge, Opera) and Node can; Firefox and Safari have not shipped it.supportsStreamingUpload()answers this without network traffic by constructing aRequestand checking that the runtime read theduplexmember and did not stringify the stream. - The path to the relay carries it. Chromium sends a streamed body over HTTP/2 or HTTP/3 only and rejects the request on an HTTP/1.1 connection, and something in front of the relay may demand a
Content-Length. The SDK verifies each relay origin once per session with a streamed POST of three bytes to/api/mte-echo. Any HTTP answer, including the echo's 405, proves the body was delivered; a rejection before any answer marks the origin unsupported; a timeout leaves it unverified and the next body probes again. A 411 from the path on a real request also marks the origin, and that request is restaged and sent again on a fresh pair. - The body is worth it. By default a body that fits one DATA frame is sent staged with a known length, which the browser can replay by itself on a stale connection, and a body that needs more than one frame streams.
{ minBytes: n }streams every body ofnbytes or more instead, and{ minBytes: 0 }streams every body that has one. A per requestrequestStreaming: truestreams whatever the size;falsestages that one body.
A caller may also pass a ReadableStream as the body itself. On a runtime and a relay that can carry it the stream is framed as it is read, with no size known up front, so the relay's maxRequestBodyBytes is enforced by the relay's 481 rather than before the send. Where streaming is off or unavailable the stream is read to its end and sent staged, never as the string "[object ReadableStream]". A caller's stream cannot be replayed, so a registry retry on such a request surfaces the error instead.
import {
mteFetch,
requestStreamingStatus,
supportsStreamingUpload,
verifyRequestStreaming,
} from "mte-relay-browser";
supportsStreamingUpload(); // true on Chromium 105+ and Node, false on Firefox and Safari
// Settle the verdict for a relay before the first upload, rather than on it.
const status = await verifyRequestStreaming("https://relay.example.com");
// { supported, enabled, minBytes, endpoint: "verified" | "unsupported" | "unverified",
// evidence: "probe" | "response" | "none", active }
requestStreamingStatus("https://relay.example.com").active; // what the next large body will doMteRelayClient exposes the same two as methods. The relay reads a streamed body exactly as it reads a staged one: one REQUEST, then DATA frames to END, forwarded upstream as they decode. A proxy in front of the relay that buffers request bodies still works; it only delays when the origin sees the first byte.
Public API
From src/mte-relay/index.ts:
initMteRelayClient(options)mteFetch(resource, init?, mteOptions?)supportsStreamingUpload(),requestStreamingStatus(relayOrigin?),verifyRequestStreaming(relayOrigin)— streamed request bodies, see the section aboveMteRelayCapacityError— thrown when the pair pool is exhaustedMteRelayRequestTooLargeError— thrown before upload when a body exceeds the relay's advertisedmaxRequestBodyBytes; carriessizeandlimitPoolEvent/PoolEventType— types for theonPoolEventcallback
initMteRelayClient(options)
Initializes singleton bootstrap and configuration. Must be called before mteFetch.
Per-server authentication and pairing occur lazily on first request to each server origin.
mteFetch(resource, init?, mteOptions?)
Fetch-compatible request API:
(
resource: RequestInfo | URL,
init?: RequestInit,
mteOptions?: { encodeType?: "MTE" | "MKE"; unencryptedHeaders?: string[] },
) => Promise<Response>;Requests must resolve to a normal HTTP(S) origin. The SDK does not support a configured relay URL fallback.
The first two parameters stay fetch-compatible. MTE-specific request overrides live in the optional third argument so they do not pollute RequestInit.
A per request unencryptedHeaders list replaces the init list for that call only and is validated before any network activity.
Quick Start
import { initMteRelayClient, mteFetch } from "mte-relay-browser";
await initMteRelayClient({
companyName: "your-company",
licenseKey: "your-license-key",
defaultEncodeType: "MKE",
});
const response = await mteFetch("https://server-a.example.com/api/mte-echo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "hello" }),
});
const statsResponse = await mteFetch("https://server-b.example.com/stats");
const strictMteResponse = await mteFetch(
"https://server-b.example.com/audit",
{
method: "POST",
},
{
encodeType: "MTE",
},
);Request / Response Behavior
- Request method/path/headers/body are packed into an MTE frame.
- Relay transport always uses:
- Method:
POST - Header:
Content-Type: application/octet-stream, plus any caller headers listed inunencryptedHeaders, plus anX-MTE-Relay-Routerouting token once the relay has issued one at auth (the client level token on auth, pair, and keepalive; the pair id on framed requests) credentialsas configured (default"include")
- Method:
- Requests whose path matches a pass through route advertised by the relay are sent as plain
fetchcalls with the configuredcredentials. No frame, no pair. - On response:
- If relay returns native
text/event-stream, SDK preserves the streaming body and returns it as a normal browserResponse. - If payload starts with
MTE, SDK decodes response metadata first. - If the framed response uses
mteType=1, SDK returns a synthetic browserResponseimmediately and streams the body by running one progressiveStartDecrypt/DecryptChunk/FinishDecryptsession across the response body bytes, including decodedtext/event-streamresponses. - Requests that explicitly use
encodeType: "MTE"fail with a protocol error if the relay returns an MKE-framed response. - Otherwise, SDK buffers the remaining framed body, decodes it, and returns upstream status/headers/body.
- If payload is non-MTE, SDK returns raw response payload as-is.
- If relay returns native
Header encryption
Every caller header is encrypted into the request metadata by default. unencryptedHeaders is a denylist of names that travel as ordinary HTTP headers on the relay POST instead.
await initMteRelayClient({
companyName: "your-company",
licenseKey: "your-license-key",
unencryptedHeaders: ["traceparent", "x-request-id"], // default [] = encrypt everything
credentials: "include", // default "include"
});
await mteFetch(url, init, { unencryptedHeaders: [] }); // per request override, replaces the init list[](the default) encrypts every header.- Listed names are removed from the encrypted set and sent plain.
["*"]sends every header plain except the two reserved names below."*"must be the only entry.- Names are trimmed and lower cased. Each must be a valid HTTP header token. Anything else throws at
initMteRelayClientor, for a per request list, before any network activity.
Reserved names, always encrypted even under ["*"]. Listing either throws.
| Name | Why |
| --- | --- |
| content-type | The relay POST must carry application/octet-stream or the server does not parse the frame. |
| x-mte-relay-route | The relay's sticky routing token. The SDK owns it; a caller must not put a value on the wire under this name. |
Facts to know:
- Names are matched case insensitively. The server canonicalizes on receipt, so
x-tenant-idarrives at the origin asX-Tenant-Idfrom either channel. - Forbidden browser header names (
host,cookie,origin,referer,content-length,connection,accept-encoding,sec-*, and the rest of the Fetch standard's list) never reach the SDK. The browser drops them fromnew Request(...). Listing one is accepted and does nothing. - One value per name. Repeated
Headers.appendcalls are joined by the browser into one comma separated value in both channels. - Size cap. The path and encrypted headers travel together as one JSON object inside the metadata section, and that section must fit in 65535 bytes once encoded. The bound on the JSON plaintext depends on the encode type, because standard MTE expands the plaintext about 8x while MKE adds only a few hundred bytes:
MAX_METADATA_PLAINTEXT_BYTES_MKEis 61439 andMAX_METADATA_PLAINTEXT_BYTES_MTEis 7168 (both exported from the protocol constants and mirroring the server's bounds). The SDK checks the JSON byte count against the bound for the request's encode type before encoding. Overflow throws a non repairableMteRelayProtocolErrornaming the section, its size, the bound, and the encode type; nothing is truncated and no pair is replaced. - Sending a header plain does not mean the origin receives it. The relay forwards plain hop headers to the origin only by its per domain
forward_plain_headerslist (default none) and forwards cookies byforward_browser_cookies(default on). Encrypted always wins on a name collision at the server. The SDK warns once per name when discovery or the per requestx-mte-plain-forwardedsignal shows a plain header was not forwarded. - CORS. The relay POST is already preflighted because
application/octet-streamis not a safelisted content type. Whencors_originsis set on the relay domain,cors_allowed_headersmust listContent-Type,X-MTE-Relay-Route, and every plain header name, because the configured list replaces the server default rather than extending it. Browsers ignoreAccess-Control-Allow-Headers: *on credentialed requests, so a wildcard does not help. If the operator overridescors_exposed_headers, it must includeX-MTE-Relay-Routeor the SDK cannot read the routing token. - Cookies. Page JavaScript cannot read HttpOnly cookies or set
Cookie, so cookies never enter the frame. The browser attaches them to the relay POST whencredentialsallows it."include"(the default) is required for any relay that is not same origin with the page. Cookies only stick when the relay host is same site with the page (a subdomain); cross site relay hosts hit third party cookie blocking in Safari and Chrome, and no relay setting changes that. - Security. Plain headers, including
authorizationor anything under["*"], are protected only by TLS on the client to relay hop and are readable and writable by any TLS terminating proxy or load balancer in front of the relay. Encrypted headers are not.
Verification recipe: point mteFetch at the jsonplaceholder /echo endpoint through the relay and read the X-Echo-Headers response header, which is the origin's view of the request headers as JSON. Compare with the relay POST in devtools.
Relay discovery
GET /api/mte-relay may return a relay object next to clientId. The SDK reads it at session init and on every repair, so relay config changes are picked up the next time the session re authenticates. Every field is optional. A relay that returns none behaves exactly as before.
| Field | What the SDK does |
| --- | --- |
| protocolVersion | Throws at session init if the relay speaks a newer frame protocol than this SDK (currently 1). |
| encodeTypes | Throws at session init if the session's encode type is not accepted, listing the accepted types. |
| kyberStrength | Throws at session init if it differs from the MTE build's Kyber strength. |
| sequenceWindow | Adopted when you did not set sequenceWindow. Warns once if you set a different value. |
| sessionTimeoutSeconds | Keepalive interval becomes one third of the timeout (clamped to 1 to 10 minutes) when you did not set keepAliveIntervalMs. Warns once if your value exceeds one third. |
| maxRequestBodyBytes | Bodies over the limit throw MteRelayRequestTooLargeError before anything is sent. This is a pre filter on the raw body; the relay applies the limit to the whole frame, so a body just under the limit can still get a 413 once metadata and MTE overhead are added. |
| maxPairsPerClient | Clamps maxPairs, initialPairs, and minPairs to the cap and warns once. |
| passThroughRoutes | Matching paths (exact, or prefix when the entry ends in *) are sent as plain fetch calls. A credentials value in the request init wins; otherwise the configured mode applies. The route list arrives with the auth response, so the first request to a relay origin still authenticates and pairs before any pass through call. |
| forwardPlainHeaders | Each name in unencryptedHeaders is checked against the list (* globs supported, ["*"] means all). Warns once per name that will not reach the origin. |
| forwardBrowserCookies | Warns once when true and credentials is "omit". |
| sseWriteDeadlineSeconds | A stream that fails after that much idle time gets an error explaining that the relay closes idle streams and the upstream must send a heartbeat sooner. |
| configVersion, serverVersion | Logged at session init and when configVersion changes between auths. |
Warnings go through console.warn, at most once per session per subject. Pass warnings: false to silence them.
Error Handling
MteRelayError.requestId and MteRelayTransportError.requestId preserve
the response's X-MTE-Relay-Request-Id, including failures while reading a stream.
Search server logs for the same value in correlationId. The relay generates an ID
when no tracing ID was supplied; the SDK leaves requestId null when none was received.
Relay HTTP 559–569 throws an error object with:
statusCode: relay status coderesponseText: plain text relay bodyrepaired: whether repair flow was executed
Behavior:
- Relay HTTP
559–569→ log the relay status, drop all pairs for that origin, re-authenticate, re-pair, then throw a repaired error without retrying the original request - Local MTE encode/decode or frame parsing failures → log the MTE status when available, drop all pairs for that origin, re-authenticate, re-pair, then throw a repaired
MteRelayProtocolError - Pool capacity exhaustion (
maxPairsreached with no idle pair) → throwMteRelayCapacityErrorimmediately; repair proceeds in the background if needed - Body larger than the relay's advertised
maxRequestBodyBytes→ throwMteRelayRequestTooLargeErrorbefore any upload; the pool is untouched - Request metadata JSON (path plus encrypted headers) larger than the bound for the encode type (61439 bytes for MKE, 7168 for MTE) → throw
MteRelayProtocolErrornaming the section, size, bound, and encode type before any MTE encode; the pool is untouched - Structural response frame errors (length mismatch, truncated section, zero length metadata, metadata that is not the expected JSON object) → throw a non repairable
MteRelayProtocolError; these are deterministic, so the pool is untouched and no pair is replaced
MteRelayCapacityError
Thrown when the pair pool is at maxPairs and no encoder or decoder is immediately available. It is distinguishable from relay HTTP errors and protocol errors by checking instanceof MteRelayCapacityError.
import { MteRelayCapacityError } from "mte-relay-browser";
try {
await mteFetch("https://api.example.com/data");
} catch (error) {
if (error instanceof MteRelayCapacityError) {
console.error(`Pool exhausted at maxPairs=${error.maxPairs}. Retry later.`);
return;
}
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
"repaired" in error
) {
console.error(error);
// decide whether and when to retry
}
}Concurrency Model
- Browser runtime is single-threaded.
- SDK maintains a pair pool for each origin plus encode type in use, starting with
initialPairspairs (default 8) and growing up tomaxPairs(default 15). - Requests select idle pairs round-robin using internal request counter.
- Each pair has a serialized queue to preserve MTE state ordering, and queued or in-flight work makes that pair busy for new selection.
- When idle pair count drops to or below
minPairs, the SDK starts a background refill of up tominPairsadditional pairs (bounded bymaxPairs). - When all pairs are busy and the pool is below
maxPairs, the SDK triggers a background refill and waits for a pair to become available. - When all pairs are busy and the pool is already at
maxPairs,mteFetchthrowsMteRelayCapacityErrorimmediately. - Requests expecting SSE reserve their selected pair until the response stream completes, errors, or is canceled. Reserving a pair immediately triggers a background refill to maintain spare capacity.
- Different pairs can progress concurrently across asynchronous requests.
Troubleshooting
Pairing fails (/api/mte-pair)
- Confirm relay URL is correct.
- Check relay server logs for request validation errors.
- Ensure client/server clock skew and session handling are healthy.
- If a dynamic append attempt fails while the pool is saturated, the SDK repairs the affected session before surfacing the append failure.
Frequent relay errors 559/564
- Session expiration or state drift is likely.
- SDK logs the relay status, performs a full re-pair for that origin, and then surfaces the repaired error.
- Caller should decide whether to retry based on endpoint semantics.
Local MTE encode/decode failures
- These failures also trigger a full per-origin re-pair.
- Check the browser console for the logged MTE status code or frame offset details.
Non-MTE response returned
- Relay may have returned plain-text error or non-frame response.
- SDK intentionally returns raw response in that case.
SSE request does not stream
- Confirm the decoded upstream response header is
Content-Type: text/event-stream. - If the relay returns SSE inside an MKE frame, confirm the decoded metadata reports
mteType=1and the request's effective encode type is"MKE". - MKE-framed SSE uses the same progressive
StartDecrypt/DecryptChunk/FinishDecryptpath as other MKE-framed response bodies. - Requests that explicitly set
encodeType: "MTE"fail with a protocol error if the relay returns an MKE-framed SSE response.
Inspecting SSE chunk bytes
Enable bounded stream diagnostics when you need to prove whether JSON escape bytes stay intact through the SDK boundary:
import {
initMteRelayClient,
type StreamDiagnosticEvent,
} from "mte-relay-browser";
await initMteRelayClient({
companyName: "your-company",
licenseKey: "your-license-key",
streamDiagnostics: {
maxPreviewBytes: 64,
onEvent(event: StreamDiagnosticEvent) {
if (
event.channel === "native-event-stream" ||
event.channel === "mke-progressive"
) {
console.log("[stream diagnostic]", event);
}
},
},
});The diagnostic event includes:
hexPreview: bounded byte preview such as20 20 5c 6eversus20 20 0aescapedPreview: byte-oriented escaped text preview such as\\nversus\nstage: whether the bytes were seen at native relay ingress, MKE encrypted chunk ingress, decrypted chunk output, orfinishDecrypt
Use the SDK preview together with consumer-side JSON.stringify(event.data) checks. If the SDK logs 5c 6e / \\n but the browser event payload later shows 0a / \n, the corruption happened after this package handed bytes to the browser Response stream.
Non-SSE MKE response buffers unexpectedly
- Confirm the decoded frame metadata uses
mteType=1. - Confirm the request's effective encode type is
"MKE"so the client has an MKE decoder available. - Non-SSE MKE responses stream by feeding each arriving transport chunk through one
StartDecrypt/DecryptChunk/FinishDecryptsession. - Only non-MKE framed responses stay on the buffered whole-body decode path.
Periodic POSTs to /api/mte-keepalive
- Expected. Each active relay session pings every
keepAliveIntervalMs(default 5 minutes) so the server does not reap idle pairs. - The request is plain JSON (not an MTE frame) and carries the session's
clientIdplus its current pair IDs. - Network failures, timeouts, and 5xx responses are logged and ignored — the next tick retries naturally.
- A
564response (signedclientIdfailed HMAC verification) stops the loop, drops local pairs, and lets the nextmteFetchlazily re-authenticate and re-pair.
Decode failures after custom modifications
- Re-check cross-wiring rules in pairing.
- Verify nonces are used verbatim (no masking or other transformation) and encode/decode operation ordering.
Plain header never reaches the origin
- The name is not in the relay domain's
forward_plain_headerslist, or the relay predates that setting. The relay drops plain hop headers it is not told to forward. - Look for the SDK warning naming the header. It fires once per session, from discovery at init or from the per request
x-mte-plain-forwardedsignal. - Either ask the operator to add the name (globs like
x-trace-*work) or remove it fromunencryptedHeadersso it travels encrypted.
Request fails with a CORS error after adding unencryptedHeaders
- The relay domain sets
cors_allowed_headers, which replaces the server default instead of extending it, and the list does not include the new plain header name or is missingContent-TypeorX-MTE-Relay-Route. Access-Control-Allow-Headers: *does not fix it; browsers ignore the wildcard on credentialed requests.
Every cross origin request fails with a CORS error after upgrading
- The SDK now sends every relay request with
credentials: "include". A relay domain withcors_allow_credentials: falsemakes the browser reject those responses, where the previous SDK'ssame-origindefault did not send credentials cross origin. Either leave the relay default (credentials allowed) or passcredentials: "same-origin"to the SDK. - A custom
cors_allowed_headerslist that omitsX-MTE-Relay-Routefails the preflight once the relay has issued a routing token. Add it to the list.
Cookies never stick
- The relay host is not same site with the page. Third party cookie blocking in Safari and Chrome drops them, and no relay setting changes that. Put the relay on a subdomain of the page's site.
credentialsis"omit"or"same-origin"on a cross origin relay. The default"include"is required there.- The SDK warns once when the relay reports
forwardBrowserCookies: trueandcredentialsis"omit".
Session init throws about protocol version, encode type, or Kyber
- The relay's discovery object reported a value this SDK or MTE build cannot satisfy. The message names both sides.
- Protocol version: upgrade
mte-relay-browser. Encode type: changedefaultEncodeTypeor the per requestencodeType. Kyber: the MTE WASM build must match the relay'sKYBER_STRENGTH.
Security and Operational Notes
- Do not log sensitive request/response bodies in production.
- Treat
clientIdand cryptographic material as sensitive. - Prefer short transport timeouts with caller-managed retries.
Versioning and Changelog Policy
- Follow Semantic Versioning for SDK releases.
- Major: breaking API/protocol behavior
- Minor: backward-compatible features
- Patch: fixes and non-breaking improvements
- Maintain
CHANGELOG.mdfor each release with:- Added
- Changed
- Fixed
- Security (if applicable)
Release Readiness Checklist
npm run buildpassesnpm run publish:beta:dry-runshows onlydist/**package contents and no.mapfiles- Beta publish uses
npm publish --tag beta(ornpm run publish:beta) - Public API changes documented in changelog
