Files
SnowLuma/packages/sdk/REVERSE_WEBSOCKET.md

6.1 KiB

Reverse WebSocket connections

@snowluma/sdk/node can accept OneBot reverse WebSocket connections. SnowLuma connects to your SDK program and sends events; your program sends actions back over that same connection. The ordinary createWebSocketClient() continues to dial a SnowLuma WebSocket server. Neither SDK entry acts as a QQ implementation for NoneBot/AstrBot; SnowLuma's networks.wsClients already serves that use case.

import { text } from '@snowluma/sdk';
import { createReverseWebSocketServer } from '@snowluma/sdk/node';

const server = createReverseWebSocketServer({
  host: '127.0.0.1',
  port: 3002,
  path: '/onebot/v11/ws',
  accessToken: process.env.ONEBOT_ACCESS_TOKEN,
  // Optional: restrict which accounts may connect, in addition to the token.
  selfIds: ['10001'],
});

server.on('error', (error) => console.error('Reverse connection failed', error));
server.on('rejected', ({ status, reason }) => console.error('Handshake rejected', status, reason));
server.on('connection', (bot) => {
  console.log('Connected account', bot.selfId);
  bot.onMessage(async (_event, context) => {
    await context.reply(text('received'));
  });
});
server.on('disconnect', ({ client, code }) => console.log('Disconnected', client.selfId, code));
await server.listen();
// Await server.close() when shutting down your application.

Configure the matching SnowLuma account with an entry in networks.wsClients:

{
  "name": "sdk",
  "url": "ws://127.0.0.1:3002/onebot/v11/ws",
  "accessToken": "same-token-as-the-sdk-server",
  "role": "Universal",
  "messageFormat": "array",
  "reportSelfMessage": false
}

Use the address of the SDK host if the two processes run on different machines. The listener binds only to loopback by default. Authentication accepts a Bearer header or access_token query parameter. Empty/omitted tokens mean no token check. Account and role headers are still mandatory. No credentials or frame bodies are included in handshake rejection events.

Sessions and channels

connection supplies a SnowLumaReverseWebSocketSession with selfId (string), canCallActions, canReceiveEvents and the existing forward client's helpers: onMessage, middleware, waitForMessage, getStatus, message sends, request, requestStream, upload and download helpers. Signed message IDs, response errors, timeouts, cancellation and stream limits retain their existing meaning.

server.getClient(selfId) and the read-only server.clients snapshot expose live sessions. Each account owns its pending requests and event handlers. Events must match the account declared by X-Self-ID; mismatches close the session and report an error. Duplicate active channels receive HTTP 409 and do not replace a healthy session. An optional account allowlist rejects unknown accounts with HTTP 403.

Universal mode carries both actions and events. For separate channels, configure one SnowLuma Api connection and one Event connection using the same account and server address. API is also accepted in the wire header. Both share one SDK session, so an event handler's context.reply() uses the API channel. connection fires when the first channel arrives; channel fires for each accepted channel:

server.on('channel', ({ client, role }) => {
  console.log(client.selfId, role, client.canCallActions, client.canReceiveEvents);
});

Wait for canCallActions before sending actions in split mode. Calling an action on an Event-only session fails explicitly. Closing either half ends the entire session and its sibling channel. SnowLuma owns reconnect timing; the next connection creates a new session and emits connection again, where handlers should be registered anew. Old session objects cannot reconnect themselves. Pending requests, stream iterators and event waits fail on disconnect. Actions are never automatically replayed because the remote side may already have executed them. This transport does not persist events missed while disconnected.

Limits, errors and ownership

The defaults are 100 MiB per incoming message, 16 MiB of outgoing buffered bytes per channel, 128 simultaneous channels, a 30-second action timeout and a 30-second ping interval. Configure maxPayloadBytes, maxBufferedBytes, maxConnections, requestTimeoutMs and heartbeatIntervalMs as needed. A zero request timeout waits indefinitely; a zero heartbeat interval disables pings. Other limits must be positive integers. All timers are cleaned up on close. Oversized inbound messages close with 1009; outbound capacity failures reject the action without enqueueing it. A peer missing the next ping check is terminated (normally within two heartbeat intervals).

Register a server error handler: unhandled errors follow Node's EventEmitter error contract and throw. Session transport, parsing and asynchronous event-handler failures are forwarded there. Invalid peer JSON and cross-account/channel frames close the connection; startup bind errors reject listen() directly.

To reuse an HTTP or HTTPS server, pass server instead of host/port, after that server has started listening. Only the configured upgrade path belongs to the SDK. Ordinary requests and other upgrade paths remain with their existing handlers. close() releases SDK connections and listeners without closing the external server. TLS configuration remains with that HTTPS server or your proxy; there is no automatic downgrade. Internally owned listeners are closed by the SDK. Closing connections have a bounded handshake grace period before termination.

Only the Node subpath imports the ws runtime dependency; browser-facing SDK entry points retain their existing imports. TypeScript Node consumers should include their normal @types/node development dependency and enable "types": ["node"] in their TypeScript configuration.

Validation uses real local WebSockets and SnowLuma's WsClientAdapter with the actual action and stream handlers, isolated from QQ accounts. It covers Universal and split channels, identity/authentication, message replies, multi-account echoes, disconnect cleanup, limits, heartbeat, external-server ownership and published NodeNext/bundler package consumers.