Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

FieldMeaning
pluginIdThe plugin ID (for example package-ride)
versionThe plugin version (for example 1.44.0)
schemaSha256The digest of the schemas section; the schema pinning key (below)
protocolVersionThe plugin protocol major version (1 for now)
codecProfileThe wire encoding: plugin-v1-decimal-int64 (numbers)
schemasInput and output JSON schemas (component name → schema)
queries, commandsOperation definitions
modulesRealtime modules

An operation definition carries these fields:

FieldMeaning
nameThe operation name; it is the method name in client code (for example claimLoginBonus)
kind, method, pathcommand or query; the HTTP method; the full path
requestSchema, responseSchema, schemaVersionThe input and output schemas and the x-crx-schema-version to send
requestMessageType, responseMessageTypeThe {message_type} in the path and the output message type
accessplayer, server, or public
retryread (safe to repeat), same-key-command (repeat only with the same key), never
journalnone, full, or reference: how the SDK records the command locally (to continue after a reload)
idempotencyRequired, receiptRetentionSeconds, resultPathFor commands: whether the key is required, the record retention, the result query path
deprecatedDeprecation 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 schemas section; it does not cover the operation list (commands, queries) or the version field. 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

  1. Get the schema document and save it to a file. From the server: GET /v1/plugins/{plugin_id}/schema with a player session or a server key.
  2. Read the digest from the document (schemaSha256) and write it into your release manifest/CI: this is your pin value.
  3. Generate the binding with the SDK’s crx command:
npx crx plugins generate \
  --input demo-game.plugin.schema.json \
  --schema-sha256 ffc38829f30ea0dd1b829f351f905854a9875806fbc8a2bbd591815c7ee52c2f \
  --output src/generated/demo-game.ts
  1. In CI, check that the generated file is up to date; --check does 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-sha256 is required and must be the same as the schemaSha256 in 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 with 0.
  • 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 reddedildi and exits with 1. 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 is journal: "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:

  1. Build/startup time, local: when setting up a binding with createGameClient, you provide the expectation (pin) from your game’s release manifest. If the pluginId, schemaSha256, protocolVersion, codecProfile (and version if you provided it) in the binding do not match the expectation exactly, it throws CrxError (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.
  2. The real document on the server: the TypeScript SDK does not call the /schema endpoint 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:

ChangeThe client sees
The operation’s schemaVersion value increased400 invalid_argument (unknown schema version). x-crx-schema-version was sending the old one
The operation or message type was removed404 not_found
A required field was added to the input schema400 invalid_argument (a payload that does not conform to the schema)
The output schema changedThe 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 match403 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