You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
bug(stage-ui): consumer registration lost on first connect since the better-ws migration — external input:text is dropped until the stage reconnects #2305
useContextBridgeStore.initialize() registers the stage as the consumer of input:text, input:text:voice and input:voice in the chat-ingestion consumer group. On a fresh page load none of the three registrations reaches the server: Client.send() returns false because the
transport is still in its prepare phase, and neither call site checks that boolean. The store then
sets initialized = true and everything looks healthy.
Consequence: from a freshly loaded stage until its first reconnect, every input:text sent by an
external module is dropped by the runtime with no consumer registered for event delivery. The
character never answers, the browser console is clean, and on dispose() the store politely sends
three module:consumer:unregister for registrations the server never received.
How it is supposed to work
input:text, input:text:voice and input:voice are the only events whose protocol default is
consumer delivery rather than broadcast
(plugin-protocol/src/types/events.ts#L1242-L1265): { mode: 'consumer-group', group: 'chat-ingestion', selection: 'first' }.
That is deliberate, and the reason is written down in the code
(context-bridge.ts#L651-L672):
the SDK lives in every stage-web tab, each tab has its own chat orchestrator, so a broadcast input:text would be ingested by every open tab and produce duplicated output:*. Web Locks guard
the in-browser side; the consumer group is the server-side half of the same coordination — the
runtime picks exactly one consumer instead of fanning out.
So the intended shape is:
producers — external modules that send input:*. Several exist in-tree: integrations/discord-bot/src/adapters/airi-adapter.ts (sends input:text with overrides.sessionId / messagePrefix), integrations/twitter-services/src/adapters/airi-adapter.ts,
and anything else speaking the SDK.
consumer — the stage, which registers itself into chat-ingestion and ingests into the chat
orchestrator. It is the only registrant in the tree; no integration registers as a consumer, and server-sdk exposes no consumer API at all (the word does not appear in it), so this one call
site in the store is the entire registration surface.
runtime — keeps the consumer registry
(server-runtime/src/index.ts#L817-L843)
and routes each input:* to one registered consumer, or warns when there is none.
How it works now
The transport accepts messages only in state ready, but the store starts sending at module:authenticated, which arrives while the transport is still preparing:
App.vue#L99-L100
calls serverChannelStore.initialize(), then contextBridgeStore.initialize().
ensureConnected() awaits initializing.value
(channel-server.ts#L243-L248),
which is resolved from the module:authenticated handler
(#L221-L239) —
the same handler that sets connected.value = true and calls flush().
module:authenticated is delivered inside the prepare phase: the SDK performs
authenticate/announce through the prepare channel
(client.ts#L410-L457),
and the runtime answers immediately — on peer open when it runs without a token
(index.ts#L537-L544),
or in reply to module:authenticate when it runs with one
(#L620-L637).
Same loss through the queued path: if the registrations reach pendingSend first, flush()
sends them in the same preparing state, ignores the false, and clears the queue
unconditionally, so the later flush() from onReady has nothing left to send.
This is not a race: resolve() runs synchronously inside the message handler, so the continuation
after await ensureConnected() is a microtask — it always executes before the next websocket
message can move the transport to ready.
Client already has a non-silent variant next to send() — sendOrThrow() —
which is not used here.
History of regression
The registration code has not changed since the day it was written; the contract underneath it did.
2026-03-29, commit a9c281c "fix(server-): added simple message queue for helping selecting consumer"* — introduces the
consumer registry, the chat-ingestion consumer group, and the registration in context-bridge
in exactly its present shape: await serverChannelStore.ensureConnected() followed by three send() calls. At that time Client.send() only required the socket to be OPEN
(client.ts at 5c60cb7),
and at module:authenticated the socket is open — so the registration went through.
2026-06-18, PR feat(better-ws): added new package #1989feat(better-ws): added new package — the SDK moves onto better-ws,
which introduces the open → preparing → ready state machine and performs authenticate/announce
inside a prepare phase. From then on send() requires ready, while the store still fires at module:authenticated, which now lands in preparing.
So the mechanism the March fix was built for — one consumer per input event instead of a broadcast —
is inoperative on a freshly loaded stage. In the browser the duplicate-response symptom of #1387 / #1223
is still held back by the Web Locks guard, but the server-side half of that protection is not
in place until the stage reconnects.
(That timeline is read from the code and the commit history, not measured — I did not run the
pre-better-ws SDK.)
Why this has probably gone unnoticed
Two independent reasons:
Everything in-tree is a producer. Discord, Twitter and friends only sendinput:*; the
stage is the only consumer, and its registration is a single call in a single place. The
stage's own chat never travels through the bus, so a missing registration is invisible unless
someone external is talking to a freshly loaded stage.
The bug heals itself on the first reconnect.registerConsumers() is also registered as an onReconnected callback, and those callbacks run from onReady
(channel-server.ts#L193-L218) —
i.e. in state ready, where sending works. So any long-running deployment repairs itself at the
first network blip or runtime restart, and only a freshly started pair (stage + runtime, no
disconnects yet) stays broken. Measured below.
Not verified, but it follows from the same code path: a Discord message sent to a stage that has
just been loaded should hit exactly the same window.
Reproduction 1 — the drop, without a browser
The store's behaviour replayed against the real runtime with the real SDK: the same registration
payload sent twice, once from the module:authenticated handler (what the store does today), once
one tick after connect() resolves.
// repro-consumer-register.mjs// Deps: "@proj-airi/server-sdk": "file:<airi>/packages/server-sdk"importprocessfrom'node:process'import{Client}from'@proj-airi/server-sdk'constURL=process.env.AIRI_WS_URL??'ws://localhost:6121/ws'constTOKEN=process.env.AIRI_WS_TOKEN||undefinedconstsleep=ms=>newPromise(resolve=>setTimeout(resolve,ms))constlog=(...args)=>console.info(newDate().toISOString().slice(11,23), ...args)constregistration=event=>({type: 'module:consumer:register',data: { event,mode: 'consumer-group',group: 'chat-ingestion'},})constdelivered=[]conststage=newClient({name: 'stage-web',url: URL,token: TOKEN,autoConnect: false,possibleEvents: ['input:text','input:text:voice','module:consumer:register','error'],onError: error=>log('stage error:',error?.message??error),})// A — what channel-server.ts does today: send from the module:authenticated handlerstage.onEvent('module:authenticated',()=>{log(`A: send(register input:text) during module:authenticated -> ${stage.send(registration('input:text'))}`)})stage.onEvent('input:text',event=>delivered.push(['input:text',event.data.text]))stage.onEvent('input:text:voice',event=>delivered.push(['input:text:voice',event.data.text]))awaitstage.connect()// B — the same call after the transport reached `ready`log(`B: send(register input:text:voice) after ready -> ${stage.send(registration('input:text:voice'))}`)awaitsleep(500)constcore=newClient({name: 'external-module',url: URL,token: TOKEN,autoConnect: false,possibleEvents: ['input:text','input:text:voice','error']})awaitcore.connect()// Both events default to delivery { mode: 'consumer-group', group: 'chat-ingestion' }core.send({type: 'input:text',data: {text: 'A: registered during authenticated'}})core.send({type: 'input:text:voice',data: {text: 'B: registered after ready'}})awaitsleep(1500)log('delivered to stage:',delivered.length ? JSON.stringify(delivered) : '(nothing)')process.exit(0)
Run:
node packages/server-runtime/dist/bin/run.mjs # in another shell
node repro-consumer-register.mjs
Output:
13:57:19.066 A: send(register input:text) during module:authenticated -> false
13:57:19.069 B: send(register input:text:voice) after ready -> true
13:57:21.090 delivered to stage: [["input:text:voice","B: registered after ready"]]
Same payload, same group, same connection — the only difference is the moment it is sent. Server
log for that run:
Identical result with AUTHENTICATION_TOKEN on the runtime and AIRI_WS_TOKEN on the clients
(A -> false, B -> true): only the trigger for module:authenticated changes, not the transport
state it arrives in.
Reproduction 2 — the reconnect heals it
Same setup, but the script starts the runtime itself, restarts it mid-way, and re-registers from onReady exactly like the onReconnected callback does:
The part that matters:
conststage=newClient({name: 'stage-web',url: URL,autoConnect: false,possibleEvents: ['input:text','module:consumer:register','error'],onReady: ()=>{readyCount+=1if(readyCount>1)// channel-server.ts runs the onReconnected callbacks herelog(`reconnect: send(register input:text) from onReady -> ${stage.send(registration('input:text'))}`)},})stage.onEvent('module:authenticated',()=>{if(readyCount===0)// and this is where it registers on a first connectlog(`first connect: send(register input:text) during module:authenticated -> ${stage.send(registration('input:text'))}`)})
Full script (starts and restarts the runtime itself)
// repro-reconnect-heals.mjsimport{spawn}from'node:child_process'importprocessfrom'node:process'import{Client}from'@proj-airi/server-sdk'constRUNTIME=process.env.AIRI_RUNTIME??'<airi>/packages/server-runtime/dist/bin/run.mjs'constURL=process.env.AIRI_WS_URL??'ws://localhost:6121/ws'constsleep=ms=>newPromise(resolve=>setTimeout(resolve,ms))constlog=(...args)=>console.info(newDate().toISOString().slice(11,23), ...args)constregistration=event=>({type: 'module:consumer:register',data: { event,mode: 'consumer-group',group: 'chat-ingestion'},})functionstartRuntime(){constchild=spawn(process.execPath,[RUNTIME],{stdio: ['ignore','pipe','pipe']})returnnewPromise((resolve)=>{constonData=(chunk)=>{if(chunk.toString().includes('started on ws://')){child.stdout.off('data',onData)resolve(child)}}child.stdout.on('data',onData)child.stderr.on('data',()=>{})})}letruntime=awaitstartRuntime()log('runtime up')constdelivered=[]letreadyCount=0conststage=newClient({name: 'stage-web',url: URL,autoConnect: false,possibleEvents: ['input:text','module:consumer:register','error'],onReady: ()=>{readyCount+=1if(readyCount>1)log(`reconnect: send(register input:text) from onReady -> ${stage.send(registration('input:text'))}`)},onError: ()=>{},})stage.onEvent('module:authenticated',()=>{if(readyCount===0)log(`first connect: send(register input:text) during module:authenticated -> ${stage.send(registration('input:text'))}`)})stage.onEvent('input:text',event=>delivered.push(event.data.text))awaitstage.connect()constcore=newClient({name: 'external-module',url: URL,autoConnect: false,possibleEvents: ['input:text','error'],onError: ()=>{}})awaitcore.connect()core.send({type: 'input:text',data: {text: 'before reconnect'}})awaitsleep(1000)log(`delivered after first connect: ${JSON.stringify(delivered)}`)log('restarting runtime…')runtime.kill()awaitsleep(500)runtime=awaitstartRuntime()awaitsleep(4000)// let both clients reconnectcore.send({type: 'input:text',data: {text: 'after reconnect'}})awaitsleep(1500)log(`delivered after reconnect: ${JSON.stringify(delivered)}`)core.close()stage.close()runtime.kill()process.exit(0)
Output:
14:56:52.111 runtime up
14:56:52.157 first connect: send(register input:text) during module:authenticated -> false
14:56:53.179 delivered after first connect: []
14:56:53.179 restarting runtime…
14:56:54.228 reconnect: send(register input:text) from onReady -> true
14:56:59.497 delivered after reconnect: ["after reconnect"]
So the difference between "broken" and "working" is one reconnect, which is why this is easy to
miss in day-to-day use and painful for anyone bringing up a fresh stack.
Browser observation and workaround
Setup: runtime + stage-web (production build, single tab) + an external module that announces
itself and sends input:text. Result: runtime logs no consumer registered for event delivery,
character silent, browser console clean.
To see it on the wire, patch WebSocket.prototype.send before the app loads (I used CDP Page.addScriptToEvaluateOnNewDocument; an inline <script> at the top of index.html works too):
Over a full page load there are exactly two outgoing frames — extension:module:announce and the
heartbeat. No module:consumer:register at all: zero registration frames in a 20-second window
after load, every time I looked. Calling dispose() + initialize() on the store makes all three
appear immediately in the same trace:
Two workarounds, both confirmed in the browser: call dispose() then initialize() on the
context-bridge store after load, or simply restart the runtime once and let the stage reconnect.
The second one measured end-to-end on a live stack: right after page load the runtime logs no consumer registered and the character stays silent; after a runtime restart the stage
reconnects in 0.4 s and the very same question comes back answered, out loud.
To be precise about what is measured and what is read: the mechanism (send() returning false
during preparing) is measured at SDK level in both reproductions above; in the browser I measured
the outcome — no registration frame ever reaches the socket — but did not instrument the store to
print that boolean.
Suggested fix
Don't equate module:authenticated with a usable transport. Set connected.value = true
and call flush() from onReady only. Authentication is one step of the handshake; the
transport is writable after announce.
Stop ignoring the boolean. In flush(), requeue on false instead of clearing pendingSend unconditionally; in send(), fall back to pendingSend when Client.send()
returns false. Either one hides this bug; both together make future handshake changes fail
loudly instead of silently.
Optional, contract-level: make ensureConnected() await the actual connection (the SDK's connect() resolves on ready), so callers that await it can rely on being able to send. Today initialize() returns before the socket is open and await ensureConnected() is satisfied by
authentication alone.
Happy to send a PR — say which shape you prefer (I would start with 1 + 2). A regression test fits
at the store level: authenticate, assert no registration is on the wire yet, reach ready, assert
all three module:consumer:register frames are there — and the same assertion after a reconnect,
which is the path that currently masks the bug.
Scope
packages/stage-ui is shared, so stage-web, stage-tamagotchi and stage-pocket are affected alike;
I verified on stage-web. All line references are permalinks to main @ d768f5a; channel-server.ts and server-sdk/src/client.ts are byte-identical to that commit in the tree I
run locally, and context-bridge.ts differs only outside the registration path (my checkout is a
few days older).
Context for why I care: I am building an external Factorio bridge that talks to the runtime as a
module. Events routed by destination (spark:notify, context:update) arrive fine; everything
going through the chat-ingestion consumer group is dropped until the stage reconnects.
Possibly related
PR fix(server-*,stage-ui): WebSocket cannot reconnect correctly, improved log #1563 (fix(server-*,stage-ui): WebSocket cannot reconnect correctly) — where pendingSend, ensureConnected() and flush() come from. The queue exists to survive reconnects, so a fix
here has to keep that path working; worth reading before touching flush().
PR fix(stage-ui): resolve critical state and initialization issues #1614 (fix(stage-ui): resolve critical state and initialization issues) — an earlier pass
over the same initialization surface (idempotent initialize(), dispose() state reset). The
unchecked send result was not part of it.
PR feat(better-ws): added new package #1989 (feat(better-ws): added new package) — the migration that changed the send contract
from "socket open" to "transport ready"; the likely point of regression.
Describe the bug
useContextBridgeStore.initialize()registers the stage as the consumer ofinput:text,input:text:voiceandinput:voicein thechat-ingestionconsumer group. On a fresh page loadnone of the three registrations reaches the server:
Client.send()returnsfalsebecause thetransport is still in its prepare phase, and neither call site checks that boolean. The store then
sets
initialized = trueand everything looks healthy.Consequence: from a freshly loaded stage until its first reconnect, every
input:textsent by anexternal module is dropped by the runtime with
no consumer registered for event delivery. Thecharacter never answers, the browser console is clean, and on
dispose()the store politely sendsthree
module:consumer:unregisterfor registrations the server never received.How it is supposed to work
input:text,input:text:voiceandinput:voiceare the only events whose protocol default isconsumer delivery rather than broadcast
(
plugin-protocol/src/types/events.ts#L1242-L1265):{ mode: 'consumer-group', group: 'chat-ingestion', selection: 'first' }.That is deliberate, and the reason is written down in the code
(
context-bridge.ts#L651-L672):the SDK lives in every stage-web tab, each tab has its own chat orchestrator, so a broadcast
input:textwould be ingested by every open tab and produce duplicatedoutput:*. Web Locks guardthe in-browser side; the consumer group is the server-side half of the same coordination — the
runtime picks exactly one consumer instead of fanning out.
So the intended shape is:
input:*. Several exist in-tree:integrations/discord-bot/src/adapters/airi-adapter.ts(sendsinput:textwithoverrides.sessionId/messagePrefix),integrations/twitter-services/src/adapters/airi-adapter.ts,and anything else speaking the SDK.
chat-ingestionand ingests into the chatorchestrator. It is the only registrant in the tree; no integration registers as a consumer, and
server-sdkexposes no consumer API at all (the word does not appear in it), so this one callsite in the store is the entire registration surface.
(
server-runtime/src/index.ts#L817-L843)and routes each
input:*to one registered consumer, or warns when there is none.How it works now
The transport accepts messages only in state
ready, but the store starts sending atmodule:authenticated, which arrives while the transport is stillpreparing:App.vue#L99-L100calls
serverChannelStore.initialize(), thencontextBridgeStore.initialize().context-bridge.ts#L449-L451awaits
ensureConnected(), then callsregisterConsumers()(
#L436-L447).ensureConnected()awaitsinitializing.value(
channel-server.ts#L243-L248),which is resolved from the
module:authenticatedhandler(
#L221-L239) —the same handler that sets
connected.value = trueand callsflush().module:authenticatedis delivered inside the prepare phase: the SDK performsauthenticate/announce through the prepare channel
(
client.ts#L410-L457),and the runtime answers immediately — on peer open when it runs without a token
(
index.ts#L537-L544),or in reply to
module:authenticatewhen it runs with one(
#L620-L637).registerConsumers()therefore runs withconnected.value === truewhiletransport.state === 'preparing'.send()takes the direct branch(
channel-server.ts#L301-L311),Client.send()returnsfalse(
client.ts#L343-L352)because better-ws requires
state === 'ready'(
better-ws/src/client/index.ts#L406-L417),and nobody looks at the result.
Same loss through the queued path: if the registrations reach
pendingSendfirst,flush()sends them in the same
preparingstate, ignores thefalse, and clears the queueunconditionally, so the later
flush()fromonReadyhas nothing left to send.This is not a race:
resolve()runs synchronously inside the message handler, so the continuationafter
await ensureConnected()is a microtask — it always executes before the next websocketmessage can move the transport to
ready.Clientalready has a non-silent variant next tosend()—sendOrThrow()—which is not used here.
History of regression
The registration code has not changed since the day it was written; the contract underneath it did.
input:textevent twice, should only accept, and emit hook instead of mutating event #1223 (every extra stage windowadds one more duplicate response) — the runtime was broadcasting
input:textto every peer, andeach window ingested it independently.
a9c281c"fix(server-): added simple message queue for helping selecting consumer"* — introduces the
consumer registry, the
chat-ingestionconsumer group, and the registration incontext-bridgein exactly its present shape:
await serverChannelStore.ensureConnected()followed by threesend()calls. At that timeClient.send()only required the socket to beOPEN(client.ts at
5c60cb7),and at
module:authenticatedthe socket is open — so the registration went through.which introduces the
open → preparing → readystate machine and performs authenticate/announceinside a prepare phase. From then on
send()requiresready, while the store still fires atmodule:authenticated, which now lands inpreparing.So the mechanism the March fix was built for — one consumer per input event instead of a broadcast —
is inoperative on a freshly loaded stage. In the browser the duplicate-response symptom of #1387 / #1223
is still held back by the Web Locks guard, but the server-side half of that protection is not
in place until the stage reconnects.
(That timeline is read from the code and the commit history, not measured — I did not run the
pre-
better-wsSDK.)Why this has probably gone unnoticed
Two independent reasons:
input:*; thestage is the only consumer, and its registration is a single call in a single place. The
stage's own chat never travels through the bus, so a missing registration is invisible unless
someone external is talking to a freshly loaded stage.
registerConsumers()is also registered as anonReconnectedcallback, and those callbacks run fromonReady(
channel-server.ts#L193-L218) —i.e. in state
ready, where sending works. So any long-running deployment repairs itself at thefirst network blip or runtime restart, and only a freshly started pair (stage + runtime, no
disconnects yet) stays broken. Measured below.
Not verified, but it follows from the same code path: a Discord message sent to a stage that has
just been loaded should hit exactly the same window.
Reproduction 1 — the drop, without a browser
The store's behaviour replayed against the real runtime with the real SDK: the same registration
payload sent twice, once from the
module:authenticatedhandler (what the store does today), onceone tick after
connect()resolves.Run:
node packages/server-runtime/dist/bin/run.mjs # in another shell node repro-consumer-register.mjsOutput:
Same payload, same group, same connection — the only difference is the moment it is sent. Server
log for that run:
Identical result with
AUTHENTICATION_TOKENon the runtime andAIRI_WS_TOKENon the clients(
A -> false,B -> true): only the trigger formodule:authenticatedchanges, not the transportstate it arrives in.
Reproduction 2 — the reconnect heals it
Same setup, but the script starts the runtime itself, restarts it mid-way, and re-registers from
onReadyexactly like theonReconnectedcallback does:The part that matters:
Full script (starts and restarts the runtime itself)
Output:
So the difference between "broken" and "working" is one reconnect, which is why this is easy to
miss in day-to-day use and painful for anyone bringing up a fresh stack.
Browser observation and workaround
Setup: runtime + stage-web (production build, single tab) + an external module that announces
itself and sends
input:text. Result: runtime logsno consumer registered for event delivery,character silent, browser console clean.
To see it on the wire, patch
WebSocket.prototype.sendbefore the app loads (I used CDPPage.addScriptToEvaluateOnNewDocument; an inline<script>at the top ofindex.htmlworks too):Over a full page load there are exactly two outgoing frames —
extension:module:announceand theheartbeat. No
module:consumer:registerat all: zero registration frames in a 20-second windowafter load, every time I looked. Calling
dispose()+initialize()on the store makes all threeappear immediately in the same trace:
Two workarounds, both confirmed in the browser: call
dispose()theninitialize()on thecontext-bridge store after load, or simply restart the runtime once and let the stage reconnect.
The second one measured end-to-end on a live stack: right after page load the runtime logs
no consumer registeredand the character stays silent; after a runtime restart the stagereconnects in 0.4 s and the very same question comes back answered, out loud.
To be precise about what is measured and what is read: the mechanism (
send()returningfalseduring
preparing) is measured at SDK level in both reproductions above; in the browser I measuredthe outcome — no registration frame ever reaches the socket — but did not instrument the store to
print that boolean.
Suggested fix
module:authenticatedwith a usable transport. Setconnected.value = trueand call
flush()fromonReadyonly. Authentication is one step of the handshake; thetransport is writable after announce.
flush(), requeue onfalseinstead of clearingpendingSendunconditionally; insend(), fall back topendingSendwhenClient.send()returns
false. Either one hides this bug; both together make future handshake changes failloudly instead of silently.
ensureConnected()await the actual connection (the SDK'sconnect()resolves onready), so callers that await it can rely on being able to send. Todayinitialize()returns before the socket is open andawait ensureConnected()is satisfied byauthentication alone.
Happy to send a PR — say which shape you prefer (I would start with 1 + 2). A regression test fits
at the store level: authenticate, assert no registration is on the wire yet, reach
ready, assertall three
module:consumer:registerframes are there — and the same assertion after a reconnect,which is the path that currently masks the bug.
Scope
packages/stage-uiis shared, so stage-web, stage-tamagotchi and stage-pocket are affected alike;I verified on stage-web. All line references are permalinks to
main@d768f5a;channel-server.tsandserver-sdk/src/client.tsare byte-identical to that commit in the tree Irun locally, and
context-bridge.tsdiffers only outside the registration path (my checkout is afew days older).
Context for why I care: I am building an external Factorio bridge that talks to the runtime as a
module. Events routed by destination (
spark:notify,context:update) arrive fine; everythinggoing through the
chat-ingestionconsumer group is dropped until the stage reconnects.Possibly related
fix(server-*,stage-ui): WebSocket cannot reconnect correctly) — wherependingSend,ensureConnected()andflush()come from. The queue exists to survive reconnects, so a fixhere has to keep that path working; worth reading before touching
flush().fix(stage-ui): resolve critical state and initialization issues) — an earlier passover the same initialization surface (idempotent
initialize(),dispose()state reset). Theunchecked send result was not part of it.
module:authenticate/module:authenticatedexchange the store keys off. The assumption "authenticated ⇒ can send"predates the prepare-phase handshake, which is how the two drifted apart.
fix(discord): bound and fairly schedule retained input work) — the busiest produceron this exact path; if the reasoning above holds, its first-load window has the same hole.
feat(better-ws): added new package) — the migration that changed the send contractfrom "socket open" to "transport ready"; the likely point of regression.
input:textevent twice, should only accept, and emit hook instead of mutating event #1223 with commita9c281c— the duplicate-response reports the consumer group wasbuilt to fix, and the commit that introduced both the registry and the registration call.
theme one layer down: a
send()that reports failure only through a boolean, right next tosendOrThrow(), is easy to misuse.System Info
System: OS: Windows 11 10.0.26200 CPU: (12) x64 Intel(R) Core(TM) i5-10400F CPU @ 2.90GHz Memory: 3.28 GB / 15.92 GB Binaries: Node: 22.17.0 - C:\Program Files\nodejs\node.EXE npm: 10.9.2 - C:\Program Files\nodejs\npm.CMD Browsers: Chrome: 151.0.7922.138 Edge: Chromium (151.0.4129.86) Internet Explorer: 11.0.26100.8115Validations
Contributions