{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://general-workers.docs.virtufin.com/schema/websocketfeed.config.schema.json",
  "title": "WebSocketFeed worker config",
  "x-feed-version": "0.3.2",
  "description": "The config of a WebSocketFeed worker (Virtufin.Worker.WebSocketFeed), decoded. On the wire (CreateWorker config) every value is a string: a string key is its value; a boolean is \"true\"/\"false\"; a number is its decimal text; an array or object key (handshake, routing, subscribe, topicitems, topics, maxitemsperchannel as an object) is its JSON text. The venue presets (market-data-devkit feeds/<preset>.json, \"config\") are this decoded form; VenueFeeds.Config / feeds.config encode them. Custom keys, e.g. for {config:KEY} placeholders, start with x followed by lowercase letters or digits (config keys are also CloudEvent extension attributes); any other unknown key is an error here. Keys marked x-read-by are read by the WorkManager, not the feed. What a feed serves is a set of topics (topics, changed at runtime by the serve command), each mapped to one venue item by the topicitems rules; consumers never talk to a feed, they subscribe to their topics.",
  "type": "object",
  "properties": {
    "url": {
      "type": "string",
      "pattern": "^wss?://",
      "description": "ws:// or wss:// URL of the venue stream. Checked at start by the SSRF guard: public hosts only unless the Runtime pod allows private ones (WEBSOCKETFEED_ALLOW_PRIVATE_HOSTS, WEBSOCKETFEED_ALLOWED_HOSTS)."
    },
    "autoreconnect": {
      "type": "boolean",
      "default": true,
      "description": "Reopen a dropped socket with backoff (1 s doubling to 30 s). false: a drop or failed open ends disconnected."
    },
    "reconnectmaxattempts": {
      "type": "integer",
      "minimum": 0,
      "default": 0,
      "description": "Reconnect attempts before disconnected; 0 retries for as long as the worker runs."
    },
    "publishtopic": {
      "type": "string",
      "minLength": 1,
      "description": "Where frames go that no routing key maps (a served topic's key or an explicit routing key): venue acks, status frames, a raw feed's every frame. Unset: such frames are dropped (counted in status.counts.unpublished) until a publish command."
    },
    "routing": {
      "type": "object",
      "description": "Content-based routing: the key at key_path picks a topic: the served topic whose routing key it is (see topicitems), else keys; unmatched messages go to publishtopic. Needed by topicitems.",
      "properties": {
        "key_path": {
          "type": "string",
          "minLength": 1,
          "description": "Dot-separated path of the routing key in a message (stream, data.s, S), or several separated by ',' for a compound key whose value is theirs joined by '|' (T,S reads o|BTC/USD from {\"T\":\"o\",\"S\":\"BTC/USD\"}: two message kinds of one instrument, two topics)."
        },
        "split_arrays": {
          "type": "boolean",
          "default": false,
          "description": "Split a frame that is a JSON array by element before routing (a venue batching several instruments per frame)."
        },
        "keys": {
          "type": "object",
          "additionalProperties": {
            "type": "string",
            "minLength": 1
          },
          "default": {},
          "description": "Explicit routing key to topic (a raw feed). May be empty: a feed serving topics derives its keys from them; an explicit key wins over a derived one."
        }
      },
      "required": [
        "key_path"
      ],
      "additionalProperties": false
    },
    "handshake": {
      "type": "array",
      "items": {
        "type": "object",
        "description": "One login step: send exactly one of text or base64, then (with expect or fail) read frames until one matches: expect continues, fail fails the login (a step with only fail waits for one frame).",
        "properties": {
          "text": {
            "type": "string",
            "description": "A text frame template. Placeholders: {secret:NAME} (the worker secret NAME, resolved on every open, never stored, logged or reported) and {config:KEY} (this config's value KEY). Placeholders are {name} or {name:arg} with a lowercase name; only the names listed are allowed (any other fails the start). A template starting with '[' or a '{' that opens no placeholder is a JSON template: string values are JSON-escaped and the rendering must be one JSON value."
          },
          "base64": {
            "type": "string",
            "contentEncoding": "base64",
            "description": "A binary frame, base64. No placeholders."
          },
          "expect": {
            "type": "string",
            "format": "regex",
            "description": "Regular expression (1 s match timeout) a reply must match to continue."
          },
          "fail": {
            "type": "string",
            "format": "regex",
            "description": "Regular expression that fails the login when a reply matches it (the reply, cut at 200 characters, goes into the status)."
          }
        },
        "oneOf": [
          {
            "required": [
              "text"
            ],
            "not": {
              "required": [
                "base64"
              ]
            }
          },
          {
            "required": [
              "base64"
            ],
            "not": {
              "required": [
                "text"
              ]
            }
          }
        ],
        "additionalProperties": false
      },
      "description": "The login, run on every open (connect, reconnect, handoff) before anything is relayed. Absent: no login."
    },
    "handshaketimeoutms": {
      "type": "integer",
      "minimum": 1,
      "maximum": 120000,
      "default": 10000,
      "description": "Each handshake step's reply budget, in milliseconds."
    },
    "subscribeframe": {
      "type": "string",
      "minLength": 1,
      "description": "The subscribe frame template of the derived subscription: every open subscribes the items of every served topic, a serve the added ones. Unset: no derived subscription (needed by topicitems). Placeholders: {items} (a JSON array of the rendered subscribeitem of each item, inserted as JSON), {channel} (the group's channel; {channel:lower}, {channel:upper}), {seq} (1, 2, ... per socket, inserted as a number), {config:KEY}. Placeholders are {name} or {name:arg} with a lowercase name; only the names listed are allowed (any other fails the start). A template starting with '[' or a '{' that opens no placeholder is a JSON template: string values are JSON-escaped and the rendering must be one JSON value."
    },
    "unsubscribeframe": {
      "type": "string",
      "minLength": 1,
      "description": "The unsubscribe frame template (needs subscribeframe): sent for the items of topics a serve removes. Unset: such an item stays subscribed (unrouted) until the next open. Placeholders: {items} (a JSON array of the rendered subscribeitem of each item, inserted as JSON), {channel} (the group's channel; {channel:lower}, {channel:upper}), {seq} (1, 2, ... per socket, inserted as a number), {config:KEY}. Placeholders are {name} or {name:arg} with a lowercase name; only the names listed are allowed (any other fails the start). A template starting with '[' or a '{' that opens no placeholder is a JSON template: string values are JSON-escaped and the rendering must be one JSON value."
    },
    "subscribeitem": {
      "type": "string",
      "pattern": "\\{symbol(:(lower|upper))?\\}",
      "default": "{symbol}",
      "description": "One item of {items} (needs subscribeframe). A JSON template renders a JSON value, any other a JSON string. Placeholders: {symbol} (the venue symbol; {symbol:lower}, {symbol:upper}), {channel} ({channel:lower}, {channel:upper}), {config:KEY}; {symbol} is required. Placeholders are {name} or {name:arg} with a lowercase name; only the names listed are allowed (any other fails the start). A template starting with '[' or a '{' that opens no placeholder is a JSON template: string values are JSON-escaped and the rendering must be one JSON value."
    },
    "frameper": {
      "type": "string",
      "enum": [
        "channel",
        "all",
        "item"
      ],
      "default": "channel",
      "description": "How subscription frames group the items (needs subscribeframe): one frame per channel, one frame for all items, or one frame per item."
    },
    "symbolformat": {
      "type": "string",
      "pattern": "^(?=.*\\{BASE\\})(?=.*\\{QUOTE\\})",
      "default": "{BASE}_{QUOTE}",
      "description": "How a canonical pair is spelled at the venue, with {BASE} and {QUOTE}: {BASE}{QUOTE}, {BASE}/{QUOTE}, {BASE}-{QUOTE}, ..."
    },
    "symbolcase": {
      "type": "string",
      "enum": [
        "upper",
        "lower",
        "asis"
      ],
      "default": "asis",
      "description": "Letter case of the spelled symbol."
    },
    "maxitems": {
      "type": "integer",
      "minimum": 1,
      "description": "Items per connection; items beyond it (in served order) are excluded: status degraded, listed in status.excluded. An orchestrator (market-data-devkit FeedResolver) splits topics over feeds so that none is over its limits."
    },
    "maxitemsperchannel": {
      "oneOf": [
        {
          "type": "integer",
          "minimum": 1
        },
        {
          "type": "object",
          "additionalProperties": {
            "type": "integer",
            "minimum": 1
          },
          "description": "Channel to limit; \"*\" for any other channel."
        }
      ],
      "description": "Items per channel: one number for every channel, or an object of channel to number (\"*\": the default). Excluded items make the feed degraded."
    },
    "routingkeyitem": {
      "type": "string",
      "pattern": "\\{symbol(:(lower|upper))?\\}",
      "description": "The routing key of a served topic's item, for the topicitems rules without their own routingkey (needs routing, read at its key_path): the frames whose key it is go to that topic. A plain-text template (not JSON). Placeholders: {symbol} (the venue symbol; {symbol:lower}, {symbol:upper}), {channel} ({channel:lower}, {channel:upper}), {config:KEY}; {symbol} is required. Placeholders are {name} or {name:arg} with a lowercase name; only the names listed are allowed (any other fails the start). A template starting with '[' or a '{' that opens no placeholder is a JSON template: string values are JSON-escaped and the rendering must be one JSON value."
    },
    "topicitems": {
      "type": "array",
      "description": "How a served topic becomes a venue item: the first rule whose topic template a topic matches gives its channel and routing key; the symbol is the topic's {pair} (a canonical BASE_QUOTE, spelled by symbolformat/symbolcase) or {symbol} (a venue symbol as is). A venue preset fills these in from the venue's naming fragment (market-data-devkit VenueFeeds), never by hand. A topic no rule covers is refused (at start for topics, by serve), as is one whose item or routing key another served topic has: one venue item, one topic. Needs subscribeframe, routing and, for rules without their own routingkey, routingkeyitem.",
      "items": {
        "type": "object",
        "properties": {
          "topic": {
            "type": "string",
            "minLength": 1,
            "description": "The topic template, dot-separated: each segment is a literal or one whole {name} placeholder (a lowercase name) that binds that segment of a served topic; exactly one of {pair} or {symbol}. Example: act.exchange.binance.orderbook.update.{pair}."
          },
          "channel": {
            "type": "string",
            "description": "The item's channel template (default: the empty channel). Placeholders: the topic template's names ({pair}, {interval}, ...; {name:lower}, {name:upper}), each through values when it maps the bound text, and {config:KEY}. Example: kline_{interval}."
          },
          "routingkey": {
            "type": "string",
            "pattern": "\\{symbol(:(lower|upper))?\\}",
            "description": "This rule's routing-key template instead of routingkeyitem (two message kinds of one instrument on one connection: o|{symbol}, t|{symbol} with key_path T,S). Placeholders: {symbol}, {channel}, {config:KEY}; {symbol} is required. A plain-text template (not JSON)."
          },
          "values": {
            "type": "object",
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            },
            "description": "Per placeholder, the venue's spelling of a bound text where it differs ({\"interval\": {\"1mo\": \"1M\"}}); text it does not list is used as is."
          }
        },
        "required": [
          "topic"
        ],
        "additionalProperties": false
      }
    },
    "topics": {
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1
      },
      "uniqueItems": true,
      "default": [],
      "description": "The topics the feed serves from its first start (needs topicitems): each one's venue item is subscribed on every open and its frames are routed to it. Changed at runtime by the serve command ({\"topics\": [...]} sets, {\"add\": [...], \"remove\": [...]} changes; all or nothing), which is saved and wins over this key after a restart or move. Empty: the feed stays connected and subscribes nothing."
    },
    "releasewhenidleminutes": {
      "type": "integer",
      "minimum": 1,
      "description": "Opt-in self-release. Unset (the default): the feed never goes away by itself; it lives until its orchestrator deletes it, also while it serves no topic. Set: once the feed has served no topic for this many minutes it closes its socket and deletes itself (DeleteWorker). A serve that makes the set non-empty within the grace period cancels the release, and emptying it again starts the period over; a serve after the release decided is refused (status releasing), so its orchestrator creates the feed again (create or update by name)."
    },
    "subscribe": {
      "type": "array",
      "items": {
        "description": "A fixed frame: a string is a text frame as is; {\"text\": ...} or {\"base64\": ...} (only that field) is that frame; any other JSON value is sent as its JSON text."
      },
      "description": "Fixed frames sent after the login and the derived subscription on every open (a raw feed's subscription)."
    },
    "autoreload": {
      "type": "boolean",
      "default": false,
      "x-read-by": "workmanager",
      "description": "Read by the WorkManager Runtime, not the feed: false (recommended) reloads the feed only on LoadCode or a Runtime release, so library releases never bounce the socket."
    }
  },
  "patternProperties": {
    "^x[a-z0-9]*$": {
      "type": "string",
      "description": "A custom key (for {config:KEY})."
    }
  },
  "required": [
    "url"
  ],
  "additionalProperties": false,
  "dependentRequired": {
    "unsubscribeframe": [
      "subscribeframe"
    ],
    "subscribeitem": [
      "subscribeframe"
    ],
    "frameper": [
      "subscribeframe"
    ],
    "routingkeyitem": [
      "routing"
    ],
    "topicitems": [
      "subscribeframe",
      "routing"
    ],
    "topics": [
      "topicitems"
    ]
  }
}
