Bindings and schema pinning
This page describes generating typed client code from a plugin’s schema document (artifact) and pinning which schema version your client was built with. Thanks to pinning, when the server updates the plugin your client notices the mismatch instead of silently talking with the wrong fields.
What the schema document contains
GET /v1/plugins/{plugin_id}/schema and artifacts/plugins/<id>.plugin.schema.json in the repository are the same document. The top-level fields:
| Field | Meaning |
|---|---|
pluginId | The plugin ID (for example package-ride) |
version | The plugin version (for example 1.44.0) |
schemaSha256 | The digest of the schemas section; the schema pinning key (below) |
protocolVersion | The plugin protocol major version (1 for now) |
codecProfile | The wire encoding: plugin-v1-decimal-int64 (numbers) |
schemas | Input and output JSON schemas (component name → schema) |
queries, commands | Operation definitions |
modules | Realtime modules |
An operation definition carries these fields:
| Field | Meaning |
|---|---|
name | The operation name; it is the method name in client code (for example claimLoginBonus) |
kind, method, path | command or query; the HTTP method; the full path |
requestSchema, responseSchema, schemaVersion | The input and output schemas and the x-crx-schema-version to send |
requestMessageType, responseMessageType | The {message_type} in the path and the output message type |
access | player, server, or public |
retry | read (safe to repeat), same-key-command (repeat only with the same key), never |
journal | none, full, or reference: how the SDK records the command locally (to continue after a reload) |
idempotencyRequired, receiptRetentionSeconds, resultPath | For commands: whether the key is required, the record retention, the result query path |
deprecated | Deprecation information if present: since, sinceUnixS, replacement (versioning) |
A realtime module definition carries moduleId, input, state, events, signals, authorityMode, deliveryClass, interestPolicy, stateTtlMs, and sequencePolicy; the input and state messages have maxBytes, ratePerSecond, and burst (protocol).
What is schemaSha256
schemaSha256 is the SHA-256 digest of the canonical JSON (keys sorted, no whitespace, UTF-8) output of the schemas object in the document (lowercase hexadecimal, 64 characters). If the schema fields change, the digest changes; even if the version number stays the same. Therefore saying “the version matches” is not enough: the digest must match.
You can also recompute and verify the digest yourself:
import { createHash } from "node:crypto";
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
/** Anahtarları özyinelemeli sıralar; sunucudaki kurallı JSON biçimiyle aynı baytları üretir (boşluksuz). */
function canonical(value: Json): Json {
if (Array.isArray(value)) return value.map(canonical);
if (value !== null && typeof value === "object") {
const sorted: { [key: string]: Json } = {};
for (const key of Object.keys(value).sort()) sorted[key] = canonical(value[key] as Json);
return sorted;
}
return value;
}
/** Şema belgesinin `schemas` bölümünden `schemaSha256` değerini yeniden hesaplar. */
export function schemaSha256(artifact: { schemas: Record<string, Json> }): string {
return createHash("sha256").update(JSON.stringify(canonical(artifact.schemas)), "utf8").digest("hex");
}
/** Belgenin kendi bildirdiği özet, içeriğiyle uyuşuyor mu? */
export function artifactIsConsistent(artifact: { schemaSha256: string; schemas: Record<string, Json> }): boolean {
return schemaSha256(artifact) === artifact.schemaSha256;
}
Note
This computation covers only the
schemassection; it does not cover the operation list (commands,queries) or theversionfield. If an operation’s path or access type changes, the digest may not change; so when regenerating the binding, also look at the difference in the whole document.
Generating a TypeScript binding
- Get the schema document and save it to a file. From the server:
GET /v1/plugins/{plugin_id}/schemawith a player session or a server key. - Read the digest from the document (
schemaSha256) and write it into your release manifest/CI: this is your pin value. - Generate the binding with the SDK’s
crxcommand:
npx crx plugins generate \
--input demo-game.plugin.schema.json \
--schema-sha256 ffc38829f30ea0dd1b829f351f905854a9875806fbc8a2bbd591815c7ee52c2f \
--output src/generated/demo-game.ts
- In CI, check that the generated file is up to date;
--checkdoes not write the file, it compares bytes:
npx crx plugins generate --input demo-game.plugin.schema.json \
--schema-sha256 ffc38829f30ea0dd1b829f351f905854a9875806fbc8a2bbd591815c7ee52c2f \
--output src/generated/demo-game.ts --check
Behavior:
- The input document is a local file; the command does not reach the network and does not execute the document.
--schema-sha256is required and must be the same as theschemaSha256in the document; if it differs, generation is rejected. The digest is not refreshed automatically: you approve the change deliberately.- The output writes only a new file; it does not overwrite an existing file (to regenerate, delete the file). The output folder must exist beforehand and its path must not contain a symbolic link (the command compares the path with its real path); symbolic links and special files are rejected; the target must be
.ts. The input file can be at most 2 MiB. - On success it prints
CRX PLUGINS GENERATE PASS(with--check,CRX PLUGINS CHECK PASS) and exits with0. - On failure the command gives no details (so that paths and contents do not leak); it prints a single line
CRX TYPES FAIL: input/version/hash/output reddedildiand exits with1. First check the input file, the digest, and the output path. - The same input always produces the same byte output.
The generated file carries the TypeScript types from the schemas (int64 as bigint), the operation tables, the module definitions, and the embedded artifact + digest. Usage:
import { DemoGameBinding } from "./generated/demo-game.js";
const demo = client.plugin(DemoGameBinding);
await demo.queries.getGiftStatus({ includeHistory: true });
const handle = await demo.commands.claimDailyGift.prepare({ giftKey: "daily" });
Operations with access: "server" are not visible in the player client and cannot be called; call them over HTTP from your server with a server key (calling plugins). access: "public" queries are called without identity with game.publicPlugin(...).
Note
If a command definition is
retry: "same-key-command", the SDK retries on a network error only with the same key; if it isjournal: "full", an unfinished command is continued with the same key when the page is reloaded. SDK installation and journal options are on the TypeScript SDK page.
Pinning and mismatch
The client checks version compatibility in two places:
- Build/startup time, local: when setting up a binding with
createGameClient, you provide the expectation (pin) from your game’s release manifest. If thepluginId,schemaSha256,protocolVersion,codecProfile(andversionif you provided it) in the binding do not match the expectation exactly, it throwsCrxError(kind: upgrade_required,code: plugin_binding_mismatch); no request goes out. This check does not ask the server; it only verifies that the binding you hold is the binding you expect. - The real document on the server: the TypeScript SDK does not call the
/schemaendpoint on its own at runtime. To see a mismatch with your client’s old binding in case the server updated the plugin, query the document yourself at startup and compare the digest:
import { DemoGameBinding } from "./generated/demo-game.js";
/**
* Sunucudaki şema belgesini alır ve elinizdeki bağlamayla karşılaştırır. Uyuşmazsa istemciyi güncelleyin:
* yeni belgeyi alın, yeni `schemaSha256`'yı manifestinize yazın, bağlamayı yeniden üretin.
* Çerez oturumu açıkken tarayıcıda çalışır (aynı kökenden, `credentials: "include"`).
*/
export async function serverSchemaMatches(baseUrl: string, environmentId: string): Promise<boolean> {
const response = await fetch(`${baseUrl}/v1/plugins/${DemoGameBinding.pluginId}/schema`, {
credentials: "include",
headers: { "x-crx-env": environmentId },
});
if (!response.ok) throw new Error(`şema belgesi alınamadı: HTTP ${response.status}`);
const live = (await response.json()) as {
pluginId: string;
schemaSha256: string;
protocolVersion: number;
codecProfile: string;
};
return (
live.pluginId === DemoGameBinding.pluginId &&
live.schemaSha256 === DemoGameBinding.schemaSha256 &&
live.protocolVersion === DemoGameBinding.protocolVersion &&
live.codecProfile === DemoGameBinding.codecProfile
);
}
In the Unity SDK this check is ready-made: PluginClient.VerifyServerSchemaAsync() gets the server’s document and compares the pluginId, schemaSha256, protocolVersion, codecProfile (and version if given in the pin) fields with the binding’s pin. On a mismatch it throws OpenPlayException (ErrorKind.UpgradeRequired, code plugin_binding_mismatch); if the pin field is missing or broken, ErrorKind.Contract.
using System.Threading.Tasks;
using OpenPlay;
using OpenPlay.PackageRide;
/// <summary>
/// Oyuna girerken sunucudaki eklenti şemasının, pakete gömülü bağlamayla aynı olduğunu doğrular.
/// Uyuşmazlıkta (<c>UpgradeRequired</c>, kod <c>plugin_binding_mismatch</c>) oyuncuyu güncellemeye yönlendirin.
/// </summary>
public static class PluginPinCheck
{
public static async Task<bool> ServerSchemaMatches(OpenPlayClient client)
{
var rides = PackageRideClient.Create(client); // pin: pluginId, schemaSha256, protocolVersion, codecProfile, version
try
{
await rides.Plugin.VerifyServerSchemaAsync(); // GET /v1/plugins/package-ride/schema; yalnız pin alanlarını okur
return true;
}
catch (OpenPlayException ex) when (ex.Kind == ErrorKind.UpgradeRequired)
{
UnityEngine.Debug.LogWarning("Eklenti şeması değişti; istemciyi güncelleyin: " + ex.Code);
return false;
}
}
}
What the client sees on a mismatch
If the server updates the plugin and your client calls with the old binding:
| Change | The client sees |
|---|---|
The operation’s schemaVersion value increased | 400 invalid_argument (unknown schema version). x-crx-schema-version was sending the old one |
| The operation or message type was removed | 404 not_found |
| A required field was added to the input schema | 400 invalid_argument (a payload that does not conform to the schema) |
| The output schema changed | The SDK cannot decode the response according to the binding; an error of type contract |
| The plugin is not enabled for your environment / the version grant does not match | 403 forbidden |
The fix is always the same: get the current schema document, deliberately write the new schemaSha256 into your manifest, regenerate the binding, and ship the client. If old clients need to keep working for a while longer, ask your plugin provider whether the old schema version is kept on the server; the platform has no guarantee of this.
Unity binding
In the Unity SDK, the binding comes ready only for the package-ride plugin (OpenPlay.PackageRide: PackageRideBinding, PackageRideClient). A Unity binding for another plugin cannot be generated today: the SDK’s generator script is an in-repository tool and does not ship in the published package. The generator packages only operations whose access value is player or public (server-only operations do not enter the package) and accepts only the plugin-v1-decimal-int64 encoding; int64 fields become C# long.
For usage, see the PluginCall example on the Unity SDK page.
Next step
- All the version concepts (protocol, schema, deprecation): versioning.
- Number encoding: numbers and encoding.