[{"data":1,"prerenderedAt":923},["ShallowReactive",2],{"docs:/docs/self-hosting/plugins":3},{"id":4,"title":5,"body":6,"description":915,"extension":916,"meta":917,"navigation":918,"path":919,"seo":920,"stem":921,"__hash__":922},"docs/docs/self-hosting/plugins.md","Plugins",{"type":7,"value":8,"toc":904},"minimark",[9,13,17,22,246,266,277,281,285,338,379,382,452,471,474,534,552,555,575,591,605,709,720,724,727,802,815,819,824,849,889,900],[10,11,5],"h1",{"id":12},"plugins",[14,15,16],"p",{},"Everything behind kraken's wire protocol is a plugin slot. Each slot is an Erlang behaviour with built-in implementations, and you choose one per slot by short name, or by naming any module that implements the behaviour.",[18,19,21],"h2",{"id":20},"the-seven-slots","The seven slots",[23,24,25,47],"table",{},[26,27,28],"thead",{},[29,30,31,35,38,41,44],"tr",{},[32,33,34],"th",{},"Slot",[32,36,37],{},"Set with",[32,39,40],{},"Built-in short names",[32,42,43],{},"Default",[32,45,46],{},"Behaviour",[48,49,50,80,107,134,159,186,211],"tbody",{},[29,51,52,56,62,71,75],{},[53,54,55],"td",{},"Auth",[53,57,58],{},[59,60,61],"code",{},"AUTH_BACKEND",[53,63,64,67,68],{},[59,65,66],{},"static",", ",[59,69,70],{},"http",[53,72,73],{},[59,74,66],{},[53,76,77],{},[59,78,79],{},"kraken_auth",[29,81,82,85,90,98,102],{},[53,83,84],{},"Broker",[53,86,87],{},[59,88,89],{},"BROKER_BACKEND",[53,91,92,67,95],{},[59,93,94],{},"syn",[59,96,97],{},"mqtt",[53,99,100],{},[59,101,94],{},[53,103,104],{},[59,105,106],{},"kraken_broker",[29,108,109,112,117,125,129],{},[53,110,111],{},"Store",[53,113,114],{},[59,115,116],{},"STORE_BACKEND",[53,118,119,67,122],{},[59,120,121],{},"ets",[59,123,124],{},"noop",[53,126,127],{},[59,128,121],{},[53,130,131],{},[59,132,133],{},"kraken_store",[29,135,136,139,144,150,154],{},[53,137,138],{},"Control",[53,140,141],{},[59,142,143],{},"CONTROL_BACKEND",[53,145,146,67,148],{},[59,147,124],{},[59,149,70],{},[53,151,152],{},[59,153,124],{},[53,155,156],{},[59,157,158],{},"kraken_control",[29,160,161,164,173,177,181],{},[53,162,163],{},"Presence store",[53,165,166,169,170],{},[59,167,168],{},"presence_store_backend"," in ",[59,171,172],{},"sys.config",[53,174,175],{},[59,176,124],{},[53,178,179],{},[59,180,124],{},[53,182,183],{},[59,184,185],{},"kraken_presence_store",[29,187,188,191,198,202,206],{},[53,189,190],{},"Wake",[53,192,193,169,196],{},[59,194,195],{},"wake_backend",[59,197,172],{},[53,199,200],{},[59,201,124],{},[53,203,204],{},[59,205,124],{},[53,207,208],{},[59,209,210],{},"kraken_wake",[29,212,213,216,231,237,241],{},[53,214,215],{},"Delivery store",[53,217,218,169,221,223,224,227,228],{},[59,219,220],{},"delivery_store_backend",[59,222,172],{},", plus ",[59,225,226],{},"durable_delivery"," set to ",[59,229,230],{},"true",[53,232,233,67,235],{},[59,234,124],{},[59,236,121],{},[53,238,239],{},[59,240,124],{},[53,242,243],{},[59,244,245],{},"kraken_delivery_store",[14,247,248,249,251,252,257,258,261,262,265],{},"The first four can be set from the environment; the last three only in ",[59,250,172],{}," (see ",[253,254,256],"a",{"href":255},"/docs/self-hosting/configuration","Configuration","). A value that is not one of the slot's short names is used as a module name, so ",[59,259,260],{},"AUTH_BACKEND=my_auth"," makes kraken call ",[59,263,264],{},"my_auth:validate_token/1",".",[14,267,268,269,272,273,276],{},"kraken also contains ",[59,270,271],{},"kraken_presence_store_ets"," and ",[59,274,275],{},"kraken_wake_http",", which you select by module name. Their source marks both as development and test backends, not for production.",[18,278,280],{"id":279},"writing-a-backend","Writing a backend",[282,283,55],"h3",{"id":284},"auth",[286,287,292],"pre",{"className":288,"code":289,"language":290,"meta":291,"style":291},"language-erlang shiki shiki-themes github-light github-dark","-callback validate_token(AccessToken :: binary()) ->\n    {ok, AuthData :: map()} | {error, Reason :: binary()}.\n-callback revalidate_token(ActorTokenId :: binary()) ->\n    {ok, AuthData :: map()} | {error, Reason :: binary()} | {retry, Reason :: binary()}.\n%% optional\n-callback check_room_access(ActorTokenId :: binary(), Pattern :: binary()) ->\n    {ok, AllowedTopics :: list()} | {error, Reason :: binary()}.\n","erlang","",[59,293,294,302,308,314,320,326,332],{"__ignoreMap":291},[295,296,299],"span",{"class":297,"line":298},"line",1,[295,300,301],{},"-callback validate_token(AccessToken :: binary()) ->\n",[295,303,305],{"class":297,"line":304},2,[295,306,307],{},"    {ok, AuthData :: map()} | {error, Reason :: binary()}.\n",[295,309,311],{"class":297,"line":310},3,[295,312,313],{},"-callback revalidate_token(ActorTokenId :: binary()) ->\n",[295,315,317],{"class":297,"line":316},4,[295,318,319],{},"    {ok, AuthData :: map()} | {error, Reason :: binary()} | {retry, Reason :: binary()}.\n",[295,321,323],{"class":297,"line":322},5,[295,324,325],{},"%% optional\n",[295,327,329],{"class":297,"line":328},6,[295,330,331],{},"-callback check_room_access(ActorTokenId :: binary(), Pattern :: binary()) ->\n",[295,333,335],{"class":297,"line":334},7,[295,336,337],{},"    {ok, AllowedTopics :: list()} | {error, Reason :: binary()}.\n",[14,339,340,341,344,345,348,349,352,353,357,358,361,362,365,366,67,369,371,372,272,375,378],{},"Build ",[59,342,343],{},"AuthData"," with ",[59,346,347],{},"kraken_auth:build_auth_data/1"," from a map with binary keys in the same shape as the HTTP backend's ",[59,350,351],{},"client_attrs","; see ",[253,354,356],{"href":355},"/docs/self-hosting/http-auth#client-attributes","HTTP Auth Contract",". An ",[59,359,360],{},"{error, Reason}"," from ",[59,363,364],{},"validate_token"," is the reason the client sees. From ",[59,367,368],{},"revalidate_token",[59,370,360],{}," closes the connection with code ",[59,373,374],{},"4001",[59,376,377],{},"{retry, Reason}"," tries again at the next heartbeat. kraken's dispatcher keeps the 30 second cache of successful validations for every backend.",[282,380,84],{"id":381},"broker",[286,383,385],{"className":288,"code":384,"language":290,"meta":291,"style":291},"-callback start() -> ok.\n-callback connect() -> {ok, Session} | {error, term()}.\n-callback connect(AuthData) -> {ok, Session} | {error, term()}.\n-callback connect(AuthData, PersistentSession, SessionExpirySeconds) ->\n    {ok, Session, ClientId} | {error, term()}.\n-callback subscribe(Session, MqttTopic, DisplayTopic, WsPid, QoS) -> ok.\n-callback unsubscribe(Session, Topic) -> ok.\n-callback publish(Session, Topic, Data, Sender | undefined, QoS, Retain) -> ok.\n-callback disconnect(Session) -> ok.\n-callback format_shared_subscription(BaseTopic, Group) -> binary().\n-callback supports_load_balancing() -> boolean().\n-callback capabilities() -> map().   %% #{retained, shared_subscriptions, multi_region}\n",[59,386,387,392,397,402,407,412,417,422,428,434,440,446],{"__ignoreMap":291},[295,388,389],{"class":297,"line":298},[295,390,391],{},"-callback start() -> ok.\n",[295,393,394],{"class":297,"line":304},[295,395,396],{},"-callback connect() -> {ok, Session} | {error, term()}.\n",[295,398,399],{"class":297,"line":310},[295,400,401],{},"-callback connect(AuthData) -> {ok, Session} | {error, term()}.\n",[295,403,404],{"class":297,"line":316},[295,405,406],{},"-callback connect(AuthData, PersistentSession, SessionExpirySeconds) ->\n",[295,408,409],{"class":297,"line":322},[295,410,411],{},"    {ok, Session, ClientId} | {error, term()}.\n",[295,413,414],{"class":297,"line":328},[295,415,416],{},"-callback subscribe(Session, MqttTopic, DisplayTopic, WsPid, QoS) -> ok.\n",[295,418,419],{"class":297,"line":334},[295,420,421],{},"-callback unsubscribe(Session, Topic) -> ok.\n",[295,423,425],{"class":297,"line":424},8,[295,426,427],{},"-callback publish(Session, Topic, Data, Sender | undefined, QoS, Retain) -> ok.\n",[295,429,431],{"class":297,"line":430},9,[295,432,433],{},"-callback disconnect(Session) -> ok.\n",[295,435,437],{"class":297,"line":436},10,[295,438,439],{},"-callback format_shared_subscription(BaseTopic, Group) -> binary().\n",[295,441,443],{"class":297,"line":442},11,[295,444,445],{},"-callback supports_load_balancing() -> boolean().\n",[295,447,449],{"class":297,"line":448},12,[295,450,451],{},"-callback capabilities() -> map().   %% #{retained, shared_subscriptions, multi_region}\n",[14,453,454,455,458,459,462,463,466,467,470],{},"Subscribed connection processes must receive ",[59,456,457],{},"{mqtt_publish, #{topic := Topic, payload := PackedPayload}}",", where the payload is MessagePack. When ",[59,460,461],{},"Sender"," is set, wrap the data as ",[59,464,465],{},"#{\u003C\u003C\"data\">> => Data, \u003C\u003C\"_sender\">> => Sender}"," so the sending connection can drop its own copy. A shared subscription, ",[59,468,469],{},"$share/Group/Topic",", must deliver each message to exactly one member of the group.",[282,472,111],{"id":473},"store",[286,475,477],{"className":288,"code":476,"language":290,"meta":291,"style":291},"-callback init() -> ok | {error, term()}.\n-callback is_enabled() -> boolean().\n-callback log_message(Doc) -> ok | {error, term()}.\n-callback log_delivery(Doc) -> ok | {error, term()}.\n-callback mark_delivered(MessageId, ActorId, Timestamp) -> ok.\n-callback log_event(Doc) -> ok.\n-callback ack_delivery(MessageId, ActorId) -> ok | {error, term()}.\n-callback batch_ack_deliveries(ActorId, MessageIds) -> ok | {error, term()}.\n-callback get_replay_messages(Options) -> {ok, [Msg], Count} | {error, term()}.\n-callback get_undelivered_count(ActorId, AppId) -> {ok, Count} | {error, term()}.\n-callback terminate() -> ok.\n",[59,478,479,484,489,494,499,504,509,514,519,524,529],{"__ignoreMap":291},[295,480,481],{"class":297,"line":298},[295,482,483],{},"-callback init() -> ok | {error, term()}.\n",[295,485,486],{"class":297,"line":304},[295,487,488],{},"-callback is_enabled() -> boolean().\n",[295,490,491],{"class":297,"line":310},[295,492,493],{},"-callback log_message(Doc) -> ok | {error, term()}.\n",[295,495,496],{"class":297,"line":316},[295,497,498],{},"-callback log_delivery(Doc) -> ok | {error, term()}.\n",[295,500,501],{"class":297,"line":322},[295,502,503],{},"-callback mark_delivered(MessageId, ActorId, Timestamp) -> ok.\n",[295,505,506],{"class":297,"line":328},[295,507,508],{},"-callback log_event(Doc) -> ok.\n",[295,510,511],{"class":297,"line":334},[295,512,513],{},"-callback ack_delivery(MessageId, ActorId) -> ok | {error, term()}.\n",[295,515,516],{"class":297,"line":424},[295,517,518],{},"-callback batch_ack_deliveries(ActorId, MessageIds) -> ok | {error, term()}.\n",[295,520,521],{"class":297,"line":430},[295,522,523],{},"-callback get_replay_messages(Options) -> {ok, [Msg], Count} | {error, term()}.\n",[295,525,526],{"class":297,"line":436},[295,527,528],{},"-callback get_undelivered_count(ActorId, AppId) -> {ok, Count} | {error, term()}.\n",[295,530,531],{"class":297,"line":442},[295,532,533],{},"-callback terminate() -> ok.\n",[14,535,536,537,540,541,544,545,548,549,551],{},"Recording is switched on and off globally by ",[59,538,539],{},"RECORD_MESSAGES",", whichever store is configured. Nothing in v0.9.0 reads recorded messages back for clients, and nothing calls ",[59,542,543],{},"get_replay_messages"," or ",[59,546,547],{},"get_undelivered_count","; the only code that reads recorded messages is the ",[59,550,121],{}," delivery store's replay for load-balanced subscriptions.",[282,553,138],{"id":554},"control",[286,556,558],{"className":288,"code":557,"language":290,"meta":291,"style":291},"-callback report_usage(Entries) -> ok | {ok, BlockedProjectIds} | {error, term()}.\n-callback report_subscription_change(Changes) -> ok | {error, term()}.\n-callback report_webhook_failure(Failure) -> ok.\n",[59,559,560,565,570],{"__ignoreMap":291},[295,561,562],{"class":297,"line":298},[295,563,564],{},"-callback report_usage(Entries) -> ok | {ok, BlockedProjectIds} | {error, term()}.\n",[295,566,567],{"class":297,"line":304},[295,568,569],{},"-callback report_subscription_change(Changes) -> ok | {error, term()}.\n",[295,571,572],{"class":297,"line":310},[295,573,574],{},"-callback report_webhook_failure(Failure) -> ok.\n",[14,576,577,578,361,581,584,585,67,588,265],{},"kraken batches the reports itself: usage every 30 seconds, or sooner once a project reaches 100 messages; subscription changes every 500 milliseconds or 50 changes. Returning ",[59,579,580],{},"{ok, BlockedProjectIds}",[59,582,583],{},"report_usage"," replaces the set of blocked projects, and publishes from a blocked project are refused with error ",[59,586,587],{},"42920",[59,589,590],{},"monthly_quota_exceeded",[14,592,593,594,596,597,600,601,604],{},"The built-in ",[59,595,70],{}," control backend posts these to ",[59,598,599],{},"CONTROL_HTTP_URL",", with ",[59,602,603],{},"Authorization: Bearer \u003CBACKEND_SECRET>",":",[23,606,607,617],{},[26,608,609],{},[29,610,611,614],{},[32,612,613],{},"Request",[32,615,616],{},"Body",[48,618,619,639,663],{},[29,620,621,626],{},[53,622,623],{},[59,624,625],{},"POST {CONTROL_HTTP_URL}/usage",[53,627,628,631,632,635,636,265],{},[59,629,630],{},"{ \"entries\": [{ \"projectId\", \"count\", \"totalBytes\" }] }",". Answer ",[59,633,634],{},"200",", optionally with ",[59,637,638],{},"{ \"blockedProjects\": [\"...\"] }",[29,640,641,646],{},[53,642,643],{},[59,644,645],{},"POST {CONTROL_HTTP_URL}/subscriptions",[53,647,648,651,652,655,656,544,659,662],{},[59,649,650],{},"{ \"changes\": [{ \"actorTokenId\", \"topic\", \"action\", \"loadBalance\", \"loadBalanceGroup\", \"filters\" }] }",", where ",[59,653,654],{},"action"," is ",[59,657,658],{},"subscribe",[59,660,661],{},"unsubscribe"," and the last three appear only when set",[29,664,665,670],{},[53,666,667],{},[59,668,669],{},"POST {CONTROL_HTTP_URL}/webhook-failures",[53,671,672,673,67,676,67,679,67,682,67,685,67,688,67,691,67,694,67,697,67,700,67,703,67,706],{},"One failed webhook call: ",[59,674,675],{},"organizationId",[59,677,678],{},"projectId",[59,680,681],{},"appId",[59,683,684],{},"roomId",[59,686,687],{},"type",[59,689,690],{},"webhookUrl",[59,692,693],{},"requestHeaders",[59,695,696],{},"requestBody",[59,698,699],{},"responseStatus",[59,701,702],{},"errorMessage",[59,704,705],{},"node",[59,707,708],{},"timestamp",[14,710,711,712,715,716,265],{},"The ",[59,713,714],{},"/subscriptions"," reports are what an auth service needs to ",[253,717,719],{"href":718},"/docs/self-hosting/http-auth#restoring-subscriptions","restore subscriptions on reconnect",[282,721,723],{"id":722},"presence-store-wake-and-delivery-store","Presence store, wake and delivery store",[14,725,726],{},"These three slots carry features that are off by default:",[728,729,730,757,765],"ul",{},[731,732,733,736,737,67,740,67,743,67,746,749,750,753,754,756],"li",{},[734,735,163],"strong",{}," (",[59,738,739],{},"upsert/1",[59,741,742],{},"offline/1",[59,744,745],{},"mark_waking/1",[59,747,748],{},"discover/1",") keeps a presence record after its connection closes, for clients that advertise ",[59,751,752],{},"persistent"," presence. With the default ",[59,755,124],{},", presence lasts exactly as long as the connection.",[731,758,759,736,761,764],{},[734,760,190],{},[59,762,763],{},"fire/1",") would call out to bring an offline actor back when a message is routed to it. The default does nothing.",[731,766,767,736,769,67,772,67,775,67,778,67,781,67,784,787,788,227,790,792,793,795,796,798,799,801],{},[734,768,215],{},[59,770,771],{},"pending/1",[59,773,774],{},"claim/1",[59,776,777],{},"cursor_get/1",[59,779,780],{},"cursor_set/2",[59,782,783],{},"ack/1",[59,785,786],{},"is_enabled/0",") lets a load-balanced worker that reconnects replay, and claim, the messages its group missed while every member was offline. It takes effect only with ",[59,789,226],{},[59,791,230],{},". The built-in ",[59,794,121],{}," backend replays from the ",[59,797,121],{}," message store, so it also needs ",[59,800,539],{}," on.",[14,803,804,805,67,808,67,811,814],{},"The behaviour modules in the kraken source (",[59,806,807],{},"src/backends/kraken_presence_store.erl",[59,809,810],{},"kraken_wake.erl",[59,812,813],{},"kraken_delivery_store.erl",") document the maps each callback receives.",[18,816,818],{"id":817},"embedding-kraken","Embedding kraken",[14,820,821,822,604],{},"kraken can be a rebar3 dependency of your own Erlang application, which then ships its backend modules alongside kraken and sets the slots in its own ",[59,823,172],{},[286,825,827],{"className":288,"code":826,"language":290,"meta":291,"style":291},"%% rebar.config\n{deps, [\n    {kraken, {git, \"https://github.com/NoLagApp/kraken.git\", {tag, \"v0.9.0\"}}}\n]}.\n",[59,828,829,834,839,844],{"__ignoreMap":291},[295,830,831],{"class":297,"line":298},[295,832,833],{},"%% rebar.config\n",[295,835,836],{"class":297,"line":304},[295,837,838],{},"{deps, [\n",[295,840,841],{"class":297,"line":310},[295,842,843],{},"    {kraken, {git, \"https://github.com/NoLagApp/kraken.git\", {tag, \"v0.9.0\"}}}\n",[295,845,846],{"class":297,"line":316},[295,847,848],{},"]}.\n",[286,850,852],{"className":288,"code":851,"language":290,"meta":291,"style":291},"%% sys.config\n[{kraken, [\n    {auth_backend, my_auth},\n    {broker_backend, syn},\n    {store_backend, noop},\n    {control_backend, noop}\n]}].\n",[59,853,854,859,864,869,874,879,884],{"__ignoreMap":291},[295,855,856],{"class":297,"line":298},[295,857,858],{},"%% sys.config\n",[295,860,861],{"class":297,"line":304},[295,862,863],{},"[{kraken, [\n",[295,865,866],{"class":297,"line":310},[295,867,868],{},"    {auth_backend, my_auth},\n",[295,870,871],{"class":297,"line":316},[295,872,873],{},"    {broker_backend, syn},\n",[295,875,876],{"class":297,"line":322},[295,877,878],{},"    {store_backend, noop},\n",[295,880,881],{"class":297,"line":328},[295,882,883],{},"    {control_backend, noop}\n",[295,885,886],{"class":297,"line":334},[295,887,888],{},"]}].\n",[14,890,891,892,895,896,899],{},"We compiled an application this way, with kraken v0.9.0 as a dependency and an auth backend of its own, using the same Erlang image kraken's Dockerfile builds with. A release built this way also needs the rest of kraken's settings; copy them from kraken's ",[59,893,894],{},"config/sys.config.src",". ",[59,897,898],{},"kraken:stats/0"," returns the node name, the cluster's nodes, the connection count and the configured backends, for an embedding application that reports on its own health.",[901,902,903],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":291,"searchDepth":304,"depth":304,"links":905},[906,907,914],{"id":20,"depth":304,"text":21},{"id":279,"depth":304,"text":280,"children":908},[909,910,911,912,913],{"id":284,"depth":310,"text":55},{"id":381,"depth":310,"text":84},{"id":473,"depth":310,"text":111},{"id":554,"depth":310,"text":138},{"id":722,"depth":310,"text":723},{"id":817,"depth":304,"text":818},"kraken's seven pluggable slots (auth, broker, store, control, presence store, wake and delivery store), the built-in modules for each, the behaviours a custom module implements, and how to embed kraken as a rebar3 dependency.","md",{},true,"/docs/self-hosting/plugins",{"title":5,"description":915},"docs/self-hosting/plugins","Y5Sbu8n6g2PttGKTzC84O7pSMSAkrdMCpzt8R0njuuQ",1791536075025]