@@ -78,6 +78,7 @@ Use focused tests while iterating. Before handing off a code change, run the ful
|
||||
- **Shared types or actions:** run workspace typechecking as well as behavior tests. A passing Vitest run does not prove type correctness.
|
||||
- **SDK packaging:** keep the SDK's `NodeNext` build and explicit `.js` relative imports, including type-only exports. Its package tests compile fresh output and check native Node.js imports, consumer declarations, and complete action-map coverage. The SDK action catalog must match OneBot's executable names; `packages/onebot/tests/sdk-actions.test.ts` checks this without adding a runtime dependency on server code. SDK parameter/result contracts remain independent of server types; keep consumer examples under `packages/sdk/tests/fixtures` aligned with the server parser checks in `packages/onebot/tests/sdk-action-params.test.ts`.
|
||||
- **SDK streams:** preserve the single-response client methods and use the additive interfaces in [STREAMING.md](packages/sdk/STREAMING.md) for file transfers. Test framing, cancellation, bounded buffering and incomplete transfers in the SDK; `packages/onebot/tests/sdk-streaming.test.ts` checks real adapter/handler round trips in temporary storage without QQ.
|
||||
- **SDK reverse WebSocket:** Node-only listeners accept OneBot implementation connections, never substitute an outgoing SDK client for a protocol implementation. Reuse the shared client session for actions/events/streams and preserve per-account identity, API/Event pairing, bounded buffers and pending-request rejection on disconnect. Test the real runtime reverse adapter and fresh published imports; see [REVERSE_WEBSOCKET.md](packages/sdk/REVERSE_WEBSOCKET.md).
|
||||
- **SDK messaging:** preserve existing string-path and ordinary reply semantics when extending the helpers in [MESSAGING.md](packages/sdk/MESSAGING.md). Filesystem access belongs only in the optional `@snowluma/sdk/node` entry point. Media sends must await upload completion and retain failure context without automatic retries; event waits follow public middleware dispatch and release listeners on every outcome. Keep legacy handler/context types assignable when adding richer SDK-created contexts.
|
||||
- **MCP agents:** preserve existing text results, business-error envelopes, default search matching and mode gates when extending the tools. Use the server SDK's `serveStdio` entry to support modern discovery and legacy initialization from the same command; test both with freshly compiled output. Cache hints belong only on bundled catalog discovery, never execution or account data. Keep trace metadata out of action parameters and sensitive data out of diagnostics. Protocol compatibility, tracing and the additive agent features are documented in [AGENT_FEATURES.md](packages/mcp/AGENT_FEATURES.md). Use isolated HTTP and MCP transports in tests; local runtime inspection must not query a live account.
|
||||
- **MCP execution services:** keep background tasks and event connections opt-in. Persist task state before effects, never replay interrupted writes, and finish durable cleanup before draining the protocol on shutdown. Input continuations must retain their original arguments and execute at most once. Event buffers require explicit account binding, bounded storage and visible gaps. Build the bundled message preview through `packages/mcp/scripts/build-message-app.mjs`; loading a preview must not send a message. Test restart, cancellation and legacy compatibility using isolated transports, including the compiled CLI.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createReverseWebSocketServer,
|
||||
type SnowLumaReverseWebSocketServer,
|
||||
} from '../../sdk/src/node/index';
|
||||
import { WsClientAdapter } from '../src/network/ws-client-adapter';
|
||||
import { buildDispatchPayload } from '../src/event-filter';
|
||||
import { okResponse, type JsonObject, type WsRole } from '../src/types';
|
||||
import type { ApiActionContext } from '../src/api-handler';
|
||||
import { actions as uploadActions, __test as uploadTest } from '../src/actions/stream-file';
|
||||
import { actions as downloadActions } from '../src/actions/stream-download';
|
||||
import { createCompiledTestHandler, testAction } from './helpers/compiled-action-handler';
|
||||
|
||||
describe('SDK reverse server against the real OneBot reverse adapter', () => {
|
||||
const adapters: WsClientAdapter[] = [];
|
||||
let server: SnowLumaReverseWebSocketServer | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(adapters.splice(0).map((adapter) => adapter.close()));
|
||||
await server?.close();
|
||||
|
||||
for (const key of uploadTest.uploads.keys()) {
|
||||
uploadTest.cleanupUpload(key, true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['universal', 'split'] as const)(
|
||||
'%s carries events, actions and complete binary streams',
|
||||
async (mode) => {
|
||||
server = createReverseWebSocketServer({
|
||||
port: 0,
|
||||
accessToken: 'integration-test',
|
||||
selfIds: ['10001'],
|
||||
});
|
||||
const errors = vi.fn();
|
||||
server.on('error', errors);
|
||||
await server.listen();
|
||||
const { port } = server.address as AddressInfo;
|
||||
const api = createCompiledTestHandler({} as ApiActionContext, [
|
||||
...uploadActions,
|
||||
...downloadActions,
|
||||
testAction('sdk_ping', () => okResponse({ pong: true })),
|
||||
]);
|
||||
const roles: WsRole[] = mode === 'universal' ? ['Universal'] : ['Api', 'Event'];
|
||||
|
||||
for (const role of roles) {
|
||||
const adapter = new WsClientAdapter(
|
||||
`reverse-${role}`,
|
||||
{
|
||||
name: `reverse-${role}`,
|
||||
url: `ws://127.0.0.1:${port}/onebot/v11/ws`,
|
||||
accessToken: 'integration-test',
|
||||
role,
|
||||
messageFormat: 'array',
|
||||
reportSelfMessage: false,
|
||||
},
|
||||
{
|
||||
uin: '10001',
|
||||
api,
|
||||
buildLifecycleEvent: (subType) => ({
|
||||
self_id: 10001,
|
||||
post_type: 'meta_event',
|
||||
meta_event_type: 'lifecycle',
|
||||
sub_type: subType,
|
||||
}),
|
||||
buildHeartbeatEvent: () => ({
|
||||
self_id: 10001,
|
||||
post_type: 'meta_event',
|
||||
meta_event_type: 'heartbeat',
|
||||
}),
|
||||
},
|
||||
);
|
||||
adapters.push(adapter);
|
||||
adapter.open();
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(server!.getClient('10001')?.canCallActions).toBe(true);
|
||||
expect(server!.getClient('10001')?.canReceiveEvents).toBe(true);
|
||||
expect(adapters.every((adapter) => adapter.describeStatus().status === 'ok')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
const client = server.getClient('10001')!;
|
||||
expect(await client.raw('sdk_ping')).toEqual({ pong: true });
|
||||
const seen = vi.fn();
|
||||
client.on('event', seen);
|
||||
const received = client.waitForMessage((event) => event.message_id === -42, {
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
const event: JsonObject = {
|
||||
self_id: 10001,
|
||||
time: 1,
|
||||
post_type: 'message',
|
||||
message_type: 'private',
|
||||
sub_type: 'friend',
|
||||
user_id: 20001,
|
||||
message_id: -42,
|
||||
message: [{ type: 'text', data: { text: 'wire test' } }],
|
||||
raw_message: 'wire test',
|
||||
};
|
||||
|
||||
for (const adapter of adapters) {
|
||||
const own = { ...event, post_type: 'message_sent' };
|
||||
adapter.onEvent(own, buildDispatchPayload(own));
|
||||
adapter.onEvent(event, buildDispatchPayload(event));
|
||||
}
|
||||
|
||||
expect((await received).message_id).toBe(-42);
|
||||
expect(seen.mock.calls.some(([event]) => event.post_type === 'message_sent')).toBe(
|
||||
false,
|
||||
);
|
||||
const bytes = Buffer.from('reverse transport binary stream\n'.repeat(3000));
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
||||
const uploaded = await client.uploadFileStream(Readable.from([bytes]), {
|
||||
fileSize: bytes.length,
|
||||
chunkSize: 8192,
|
||||
filename: 'reverse.bin',
|
||||
expectedSha256: sha256,
|
||||
fileRetention: 0,
|
||||
});
|
||||
const chunks: Uint8Array[] = [];
|
||||
const stream = client.downloadFileStream({
|
||||
file: uploaded.file_path,
|
||||
chunk_size: 4096,
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const next = await stream.next();
|
||||
|
||||
if (next.done) {
|
||||
expect(next.value.total_bytes).toBe(bytes.length);
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(next.value);
|
||||
}
|
||||
|
||||
expect(Buffer.concat(chunks)).toEqual(bytes);
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add a Node reverse WebSocket server with authenticated per-account sessions, Universal and paired API/Event channels, existing action/event/stream helpers, bounded buffers and explicit disconnect handling; see [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md).
|
||||
|
||||
- Add one-step local media sends, the optional `@snowluma/sdk/node` file source, stage-aware errors, explicit quoted replies, and cancellable WebSocket event waits; see [MESSAGING.md](MESSAGING.md). Existing methods and handler/context types remain compatible.
|
||||
- Report group-file chat publication failures through the runtime's existing error response instead of returning a successful upload result after publication fails.
|
||||
- Add HTTP and WebSocket `requestStream()` / `stream()`, incremental `uploadFileStream()` / `downloadFileStream()` helpers, cancellation, bounded response buffering, and transfer validation. Existing single-response methods remain unchanged; see [STREAMING.md](STREAMING.md).
|
||||
|
||||
@@ -444,3 +444,7 @@ src/
|
||||
## 版本策略
|
||||
|
||||
当前包版本为 `0.1.0`。版本规则、发布检查清单和破坏性变更边界见 [VERSIONING.md](./VERSIONING.md)。
|
||||
|
||||
## 接收反向 WebSocket 连接
|
||||
|
||||
Node 程序可通过 `@snowluma/sdk/node` 的 `createReverseWebSocketServer()` 接受 SnowLuma 的反向连接,按账号使用原有消息监听、动作和流式接口。支持统一连接或分开的 API/Event 通道。配置、鉴权和重连处理见 [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md)。
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# 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.
|
||||
|
||||
```ts
|
||||
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`:
|
||||
|
||||
```json
|
||||
{
|
||||
"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:
|
||||
|
||||
```ts
|
||||
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.
|
||||
@@ -48,3 +48,5 @@ Stream result types describe individual frame payloads, including intermediate a
|
||||
The package consumer tests check complete action-map coverage, inferred results, compatibility aliases, and invalid input examples with both NodeNext and bundler resolution. The same parameter examples are checked against the server's real action parsers without starting QQ or executing actions.
|
||||
|
||||
Additive media-send methods, explicit quoted replies, and WebSocket event waits are described in [MESSAGING.md](MESSAGING.md). Existing string paths, ordinary `reply()` behavior, and handler/context types remain supported. The optional `node` subpath owns local filesystem access; browser-facing entry points stay free of Node imports. The runtime also propagates group-file chat publication failures instead of returning success after a failed publication; successful action response shapes are unchanged.
|
||||
|
||||
The optional `node` entry also exports `createReverseWebSocketServer`, `SnowLumaReverseWebSocketServer`, and reverse-session types. These additive exports accept OneBot reverse connections without changing the forward client. The `ws` runtime dependency is loaded only by the Node entry; browser-facing exports remain free of Node server imports. See [REVERSE_WEBSOCKET.md](REVERSE_WEBSOCKET.md).
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
"CHANGELOG.md",
|
||||
"STREAMING.md",
|
||||
"MESSAGING.md",
|
||||
"VERSIONING.md"
|
||||
"VERSIONING.md",
|
||||
"REVERSE_WEBSOCKET.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs = require('node:fs'); fs.rmSync('dist', { recursive: true, force: true })\"",
|
||||
@@ -96,7 +97,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "8.18.2",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "8.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -414,6 +414,16 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
|
||||
appendAccessToken(this.url, this.accessToken),
|
||||
this.protocols,
|
||||
);
|
||||
return this.attachSocket(socket);
|
||||
}
|
||||
|
||||
/** Shared protocol session for outgoing and already accepted connections. */
|
||||
protected attachSocket(socket: WebSocketLike): Promise<void> {
|
||||
if (this.connection) {
|
||||
throw new SnowLumaConnectionError('A WebSocket is already attached');
|
||||
}
|
||||
|
||||
this.closedByUser = false;
|
||||
let resolveOpening!: () => void;
|
||||
let rejectOpening!: (error: Error) => void;
|
||||
const opened = new Promise<void>((resolve, reject) => {
|
||||
@@ -430,16 +440,19 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
|
||||
},
|
||||
};
|
||||
this.connection = connection;
|
||||
listeners.push(
|
||||
addSocketListener(socket, 'open', () => {
|
||||
if (this.connection !== connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0;
|
||||
resolveOpening();
|
||||
this.emitter.emit('open', undefined);
|
||||
}),
|
||||
const openedSocket = (): void => {
|
||||
if (this.connection !== connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts = 0;
|
||||
resolveOpening();
|
||||
this.emitter.emit('open', undefined);
|
||||
};
|
||||
|
||||
listeners.push(
|
||||
addSocketListener(socket, 'open', openedSocket),
|
||||
addSocketListener(socket, 'message', (...args) => {
|
||||
if (this.connection === connection) {
|
||||
this.handleMessage(args);
|
||||
@@ -461,6 +474,10 @@ export class SnowLumaWebSocketClient extends SnowLumaApiClient {
|
||||
}),
|
||||
);
|
||||
|
||||
if (socket.readyState === WEBSOCKET_OPEN) {
|
||||
openedSocket();
|
||||
}
|
||||
|
||||
return opened;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ import { basename, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { NamedMediaSource } from '../types/index.js';
|
||||
|
||||
export {
|
||||
createReverseWebSocketServer,
|
||||
SnowLumaReverseWebSocketServer,
|
||||
type SnowLumaReverseWebSocketSession,
|
||||
type ReverseWebSocketServerOptions,
|
||||
type ReverseWebSocketServerEvents,
|
||||
} from './reverse-websocket-server.js';
|
||||
|
||||
/** Opens a file-backed source without buffering the entire file in memory.
|
||||
* Keep the file unchanged until the send finishes; read failures are propagated. */
|
||||
export async function localFile(path: string | URL): Promise<NamedMediaSource> {
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
/// <reference types="node" preserve="true" />
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createServer, type IncomingMessage, type Server } from 'node:http';
|
||||
import type { Server as HttpsServer } from 'node:https';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { SnowLumaWebSocketClient } from '../client/websocket-client.js';
|
||||
import type { WebSocketCloseInfo } from '../client/websocket-types.js';
|
||||
import { SnowLumaConnectionError } from '../errors.js';
|
||||
import type { WsRole } from '../types/index.js';
|
||||
import { ReverseWebSocketTransport } from './reverse-websocket-transport.js';
|
||||
|
||||
export interface ReverseWebSocketServerOptions {
|
||||
/** Defaults to loopback; set explicitly when accepting remote peers. */
|
||||
host?: string;
|
||||
/** Defaults to 3002. Zero allocates an ephemeral port. */
|
||||
port?: number;
|
||||
/** Defaults to /onebot/v11/ws. An optional trailing slash is accepted. */
|
||||
path?: string;
|
||||
accessToken?: string;
|
||||
/** Optional allowlist of bot accounts. Each connection still authenticates. */
|
||||
selfIds?: readonly (string | number)[];
|
||||
/** Attach to an already listening HTTP(S) server without owning its lifetime. */
|
||||
server?: Server | HttpsServer;
|
||||
requestTimeoutMs?: number;
|
||||
/** Maximum incoming message size; defaults to 100 MiB. */
|
||||
maxPayloadBytes?: number;
|
||||
/** Maximum queued outgoing bytes per channel; defaults to 16 MiB. */
|
||||
maxBufferedBytes?: number;
|
||||
/** Maximum connected channels, including separate API/Event channels. Default 128. */
|
||||
maxConnections?: number;
|
||||
/** Ping interval; defaults to 30 seconds. Zero disables keepalive. */
|
||||
heartbeatIntervalMs?: number;
|
||||
}
|
||||
|
||||
export interface ReverseWebSocketServerEvents {
|
||||
/** One account session, ready for event subscriptions immediately. */
|
||||
connection: SnowLumaReverseWebSocketSession;
|
||||
/** Emitted for each channel, including the second half of API/Event mode. */
|
||||
channel: { client: SnowLumaReverseWebSocketSession; role: WsRole };
|
||||
disconnect: { client: SnowLumaReverseWebSocketSession } & WebSocketCloseInfo;
|
||||
rejected: { status: number; reason: string };
|
||||
error: Error;
|
||||
}
|
||||
|
||||
/** A connected account; action helpers, middleware and streams match the forward SDK client. */
|
||||
export interface SnowLumaReverseWebSocketSession extends SnowLumaWebSocketClient {
|
||||
readonly selfId: string;
|
||||
readonly canCallActions: boolean;
|
||||
readonly canReceiveEvents: boolean;
|
||||
}
|
||||
|
||||
class ReverseWebSocketSession
|
||||
extends SnowLumaWebSocketClient
|
||||
implements SnowLumaReverseWebSocketSession
|
||||
{
|
||||
constructor(
|
||||
readonly selfId: string,
|
||||
private readonly transport: ReverseWebSocketTransport,
|
||||
requestTimeoutMs?: number,
|
||||
) {
|
||||
super({ url: 'reverse://accepted', requestTimeoutMs, reconnect: false });
|
||||
// Accepted sockets are already open; attachSocket installs the same
|
||||
// protocol handlers before any peer data can be dispatched.
|
||||
void this.attachSocket(transport);
|
||||
}
|
||||
|
||||
get canCallActions(): boolean {
|
||||
return this.transport.canCallActions;
|
||||
}
|
||||
get canReceiveEvents(): boolean {
|
||||
return this.transport.canReceiveEvents;
|
||||
}
|
||||
|
||||
/** Reverse peers reconnect to the server; a stale session never dials out. */
|
||||
override async connect(): Promise<void> {
|
||||
if (!this.isConnected) {
|
||||
throw new SnowLumaConnectionError(
|
||||
'Reverse session disconnected; wait for a new server connection',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Account {
|
||||
transport: ReverseWebSocketTransport;
|
||||
client: SnowLumaReverseWebSocketSession;
|
||||
}
|
||||
|
||||
export class SnowLumaReverseWebSocketServer {
|
||||
private readonly events = new EventEmitter();
|
||||
private readonly accounts = new Map<string, Account>();
|
||||
private readonly allowedIds: Set<string> | undefined;
|
||||
private readonly options: ReverseWebSocketServerOptions;
|
||||
private readonly path: string;
|
||||
private httpServer: Server | HttpsServer | null = null;
|
||||
private wsServer: WebSocketServer | null = null;
|
||||
private opening: Promise<void> | null = null;
|
||||
private closing: Promise<void> | null = null;
|
||||
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||
private readonly alive = new Set<WebSocket>();
|
||||
|
||||
constructor(options: ReverseWebSocketServerOptions = {}) {
|
||||
if (options.server && (options.host !== undefined || options.port !== undefined)) {
|
||||
throw new TypeError('server cannot be combined with host or port');
|
||||
}
|
||||
|
||||
if (options.accessToken !== undefined && typeof options.accessToken !== 'string') {
|
||||
throw new TypeError('accessToken must be a string');
|
||||
}
|
||||
|
||||
this.options = { ...options };
|
||||
const path = options.path ?? '/onebot/v11/ws';
|
||||
|
||||
if (!path.startsWith('/') || /[?#\s]/.test(path)) {
|
||||
throw new TypeError(
|
||||
'path must be an absolute URL pathname without query or whitespace',
|
||||
);
|
||||
}
|
||||
|
||||
this.path = path.replace(/\/$/, '') || '/';
|
||||
|
||||
integer('port', options.port ?? 3002, 0, 65535);
|
||||
integer('requestTimeoutMs', options.requestTimeoutMs ?? 30_000, 0);
|
||||
integer('maxPayloadBytes', options.maxPayloadBytes ?? 100 * 1024 * 1024, 1);
|
||||
integer('maxBufferedBytes', options.maxBufferedBytes ?? 16 * 1024 * 1024, 1);
|
||||
integer('maxConnections', options.maxConnections ?? 128, 1);
|
||||
integer('heartbeatIntervalMs', options.heartbeatIntervalMs ?? 30_000, 0);
|
||||
this.allowedIds = options.selfIds ? new Set(options.selfIds.map(accountId)) : undefined;
|
||||
}
|
||||
|
||||
get address(): AddressInfo | string | null {
|
||||
return this.httpServer?.address() ?? null;
|
||||
}
|
||||
get clients(): ReadonlyMap<string, SnowLumaReverseWebSocketSession> {
|
||||
return new Map([...this.accounts].map(([id, account]) => [id, account.client]));
|
||||
}
|
||||
|
||||
getClient(selfId: string | number): SnowLumaReverseWebSocketSession | undefined {
|
||||
return this.accounts.get(accountId(selfId))?.client;
|
||||
}
|
||||
|
||||
/** Unhandled error events throw, following Node's server error contract. */
|
||||
on<K extends keyof ReverseWebSocketServerEvents>(
|
||||
event: K,
|
||||
listener: (value: ReverseWebSocketServerEvents[K]) => void,
|
||||
): () => void {
|
||||
this.events.on(event, listener);
|
||||
|
||||
return () => {
|
||||
this.events.off(event, listener);
|
||||
};
|
||||
}
|
||||
|
||||
async listen(): Promise<void> {
|
||||
if (this.closing) {
|
||||
await this.closing;
|
||||
}
|
||||
|
||||
if (this.opening) {
|
||||
return this.opening;
|
||||
}
|
||||
|
||||
if (this.wsServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.server && !this.options.server.listening) {
|
||||
throw new SnowLumaConnectionError(
|
||||
'The supplied HTTP(S) server must already be listening',
|
||||
);
|
||||
}
|
||||
|
||||
const server =
|
||||
this.options.server ??
|
||||
createServer((_req, res) => {
|
||||
res.writeHead(426, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
Upgrade: 'websocket',
|
||||
});
|
||||
res.end('Use a OneBot reverse WebSocket connection');
|
||||
});
|
||||
const wsServer = new WebSocketServer({
|
||||
noServer: true,
|
||||
perMessageDeflate: false,
|
||||
maxPayload: this.options.maxPayloadBytes ?? 100 * 1024 * 1024,
|
||||
});
|
||||
this.httpServer = server;
|
||||
this.wsServer = wsServer;
|
||||
wsServer.on('error', this.onError);
|
||||
server.on('upgrade', this.upgrade);
|
||||
server.on('error', this.onError);
|
||||
server.on('close', this.onHttpClose);
|
||||
this.opening = this.options.server
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve, reject) => {
|
||||
const failed = (error: Error): void => {
|
||||
server.off('listening', ready);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const ready = (): void => {
|
||||
server.off('error', failed);
|
||||
resolve();
|
||||
};
|
||||
|
||||
server.once('error', failed);
|
||||
server.once('listening', ready);
|
||||
server.listen(this.options.port ?? 3002, this.options.host ?? '127.0.0.1');
|
||||
});
|
||||
|
||||
try {
|
||||
await this.opening;
|
||||
this.startHeartbeat();
|
||||
} catch (error) {
|
||||
this.detach();
|
||||
wsServer.close();
|
||||
throw error;
|
||||
} finally {
|
||||
this.opening = null;
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.opening) {
|
||||
try {
|
||||
await this.opening;
|
||||
} catch {
|
||||
return;
|
||||
} // listen() owns the failure.
|
||||
}
|
||||
|
||||
if (this.closing) {
|
||||
return this.closing;
|
||||
}
|
||||
|
||||
const server = this.httpServer;
|
||||
const wsServer = this.wsServer;
|
||||
|
||||
if (!server || !wsServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closing = (async () => {
|
||||
// Publish the closing promise before disconnect callbacks can call
|
||||
// listen()/close() again.
|
||||
await Promise.resolve();
|
||||
this.detach();
|
||||
|
||||
for (const account of [...this.accounts.values()]) {
|
||||
account.client.close(1001, 'server closing');
|
||||
}
|
||||
|
||||
const releaseWebSockets = new Promise<void>((resolve, reject) => {
|
||||
wsServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
const releaseHttp = this.options.server
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve, reject) => {
|
||||
server.close((error) =>
|
||||
error &&
|
||||
(error as NodeJS.ErrnoException).code !== 'ERR_SERVER_NOT_RUNNING'
|
||||
? reject(error)
|
||||
: resolve(),
|
||||
);
|
||||
server.closeAllConnections();
|
||||
});
|
||||
await Promise.all([releaseWebSockets, releaseHttp]);
|
||||
})();
|
||||
|
||||
try {
|
||||
await this.closing;
|
||||
} finally {
|
||||
this.closing = null;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onError = (error: Error): void => {
|
||||
if (!this.opening) {
|
||||
this.events.emit('error', error);
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onHttpClose = (): void => {
|
||||
void this.close().catch((error) => this.events.emit('error', error));
|
||||
};
|
||||
|
||||
private detach(): void {
|
||||
this.httpServer?.off('upgrade', this.upgrade);
|
||||
this.httpServer?.off('error', this.onError);
|
||||
this.httpServer?.off('close', this.onHttpClose);
|
||||
this.httpServer = null;
|
||||
this.wsServer = null;
|
||||
|
||||
if (this.heartbeat) {
|
||||
clearInterval(this.heartbeat);
|
||||
}
|
||||
|
||||
this.heartbeat = undefined;
|
||||
this.alive.clear();
|
||||
}
|
||||
|
||||
private readonly upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
||||
const onError = (error: Error): void => {
|
||||
this.events.emit('error', error);
|
||||
};
|
||||
|
||||
socket.on('error', onError);
|
||||
socket.once('close', () => socket.off('error', onError));
|
||||
|
||||
const reject = (status: number, reason: string): void => {
|
||||
socket.end(
|
||||
`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`,
|
||||
() => socket.destroy(),
|
||||
);
|
||||
this.events.emit('rejected', { status, reason });
|
||||
};
|
||||
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(request.url ?? '/', 'http://localhost');
|
||||
} catch {
|
||||
reject(400, 'Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname !== this.path && pathname !== `${this.path.replace(/\/$/, '')}/`) {
|
||||
if (!this.options.server) {
|
||||
reject(404, 'Not Found');
|
||||
} else {
|
||||
socket.off('error', onError);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.wsServer || this.closing) {
|
||||
reject(503, 'Service Unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const token = this.options.accessToken;
|
||||
|
||||
if (token) {
|
||||
const supplied = request.headers.authorization?.startsWith('Bearer ')
|
||||
? request.headers.authorization.slice(7)
|
||||
: (url.searchParams.get('access_token') ?? '');
|
||||
const expected = Buffer.from(token);
|
||||
const actual = Buffer.from(supplied);
|
||||
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
||||
reject(401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let selfId: string;
|
||||
|
||||
try {
|
||||
selfId = accountId(request.headers['x-self-id']);
|
||||
} catch {
|
||||
reject(400, 'Invalid Self ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const rawRole = request.headers['x-client-role'];
|
||||
const role = rawRole === 'API' ? 'Api' : rawRole;
|
||||
|
||||
if (role !== 'Api' && role !== 'Event' && role !== 'Universal') {
|
||||
reject(400, 'Invalid Client Role');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.allowedIds && !this.allowedIds.has(selfId)) {
|
||||
reject(403, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.accounts.get(selfId);
|
||||
|
||||
if (existing && !existing.transport.canAttach(role)) {
|
||||
reject(409, 'Channel Already Connected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.wsServer.clients.size >= (this.options.maxConnections ?? 128)) {
|
||||
reject(503, 'Connection Limit Reached');
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsServer.handleUpgrade(request, socket, head, (peer) => {
|
||||
socket.off('error', onError);
|
||||
this.alive.add(peer);
|
||||
peer.on('pong', () => this.alive.add(peer));
|
||||
peer.once('close', () => this.alive.delete(peer));
|
||||
let account = existing;
|
||||
|
||||
if (!account) {
|
||||
const transport = new ReverseWebSocketTransport(
|
||||
selfId,
|
||||
this.options.maxBufferedBytes ?? 16 * 1024 * 1024,
|
||||
);
|
||||
|
||||
// Observe transport errors even after the protocol client has
|
||||
// detached its own error listener during close.
|
||||
const reportError = (cause: unknown): void => {
|
||||
this.events.emit(
|
||||
'error',
|
||||
new SnowLumaConnectionError(`Reverse account ${selfId} failed`, { cause }),
|
||||
);
|
||||
};
|
||||
|
||||
transport.on('error', (cause: Error) => {
|
||||
if (!client.isConnected) {
|
||||
reportError(cause);
|
||||
}
|
||||
});
|
||||
const client = new ReverseWebSocketSession(
|
||||
selfId,
|
||||
transport,
|
||||
this.options.requestTimeoutMs,
|
||||
);
|
||||
client.on('error', reportError);
|
||||
account = { transport, client };
|
||||
this.accounts.set(selfId, account);
|
||||
client.on('close', (info) => {
|
||||
if (this.accounts.get(selfId)?.client === client) {
|
||||
this.accounts.delete(selfId);
|
||||
}
|
||||
|
||||
this.events.emit('disconnect', { client, ...info });
|
||||
});
|
||||
transport.attach(role, peer);
|
||||
this.events.emit('connection', client);
|
||||
} else {
|
||||
account.transport.attach(role, peer);
|
||||
}
|
||||
|
||||
this.events.emit('channel', { client: account.client, role });
|
||||
});
|
||||
};
|
||||
|
||||
private startHeartbeat(): void {
|
||||
const interval = this.options.heartbeatIntervalMs ?? 30_000;
|
||||
|
||||
if (interval === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.heartbeat = setInterval(() => {
|
||||
for (const peer of this.wsServer?.clients ?? []) {
|
||||
if (!this.alive.delete(peer)) {
|
||||
peer.terminate();
|
||||
} else if (peer.readyState === WebSocket.OPEN) {
|
||||
peer.ping();
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
this.heartbeat.unref();
|
||||
}
|
||||
}
|
||||
|
||||
export function createReverseWebSocketServer(
|
||||
options?: ReverseWebSocketServerOptions,
|
||||
): SnowLumaReverseWebSocketServer {
|
||||
return new SnowLumaReverseWebSocketServer(options);
|
||||
}
|
||||
|
||||
function accountId(value: unknown): string {
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
||||
throw new TypeError('selfId must be a positive integer');
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof value !== 'string' && typeof value !== 'number') ||
|
||||
!/^[1-9]\d{0,19}$/.test(String(value))
|
||||
) {
|
||||
throw new TypeError('selfId must be a positive decimal account ID');
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function integer(name: string, value: number, minimum: number, maximum = 2_147_483_647): void {
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new RangeError(`${name} must be an integer between ${minimum} and ${maximum}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
import { SnowLumaConnectionError, SnowLumaParseError } from '../errors.js';
|
||||
import { isJsonObject } from '../internal/json.js';
|
||||
import type { WsRole } from '../types/index.js';
|
||||
|
||||
/** API and Event channels share one account session and pending-request owner. */
|
||||
export class ReverseWebSocketTransport extends EventEmitter {
|
||||
readyState = WebSocket.OPEN as number;
|
||||
private readonly channels = new Map<WsRole, WebSocket>();
|
||||
|
||||
constructor(
|
||||
readonly selfId: string,
|
||||
private readonly maxBufferedBytes: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get canCallActions(): boolean {
|
||||
return this.apiSocket?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
get canReceiveEvents(): boolean {
|
||||
return (
|
||||
(this.channels.get('Universal') ?? this.channels.get('Event'))?.readyState ===
|
||||
WebSocket.OPEN
|
||||
);
|
||||
}
|
||||
|
||||
canAttach(role: WsRole): boolean {
|
||||
return (
|
||||
this.readyState === WebSocket.OPEN &&
|
||||
!this.channels.has(role) &&
|
||||
!this.channels.has('Universal') &&
|
||||
(role !== 'Universal' || this.channels.size === 0)
|
||||
);
|
||||
}
|
||||
|
||||
attach(role: WsRole, socket: WebSocket): void {
|
||||
if (!this.canAttach(role)) {
|
||||
throw new SnowLumaConnectionError('Duplicate reverse WebSocket channel');
|
||||
}
|
||||
|
||||
this.channels.set(role, socket);
|
||||
socket.on('message', (data: RawData) => this.receive(role, data));
|
||||
socket.on('error', (error: Error) => {
|
||||
this.close(1011, 'connection failed');
|
||||
this.emit('error', error);
|
||||
});
|
||||
socket.once('close', (code: number, reason: Buffer) => {
|
||||
this.close(code, reason.toString('utf8'));
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
const socket = this.apiSocket;
|
||||
|
||||
if (this.readyState !== WebSocket.OPEN || socket?.readyState !== WebSocket.OPEN) {
|
||||
throw new SnowLumaConnectionError('This reverse session has no connected API channel');
|
||||
}
|
||||
|
||||
if (socket.bufferedAmount + Buffer.byteLength(data) > this.maxBufferedBytes) {
|
||||
throw new SnowLumaConnectionError('Reverse WebSocket outbound buffer limit exceeded');
|
||||
}
|
||||
|
||||
socket.send(data, (error) => {
|
||||
if (error) {
|
||||
this.close(1011, 'send failed');
|
||||
this.emit('error', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
close(code = 1000, reason = 'normal'): void {
|
||||
if (this.readyState === WebSocket.CLOSED) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
|
||||
for (const socket of this.channels.values()) {
|
||||
if (socket.readyState === WebSocket.CLOSED) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A peer's abnormal close code cannot be sent on the wire. Closing
|
||||
// either half also ends its sibling: pending actions never survive
|
||||
// into a replacement connection with uncertain execution state.
|
||||
socket.close(code === 1005 || code === 1006 || code === 1015 ? 1001 : code, reason);
|
||||
const timer = setTimeout(() => socket.terminate(), 1000);
|
||||
timer.unref();
|
||||
socket.once('close', () => clearTimeout(timer));
|
||||
}
|
||||
|
||||
this.channels.clear();
|
||||
this.emit('close', code, reason);
|
||||
}
|
||||
|
||||
private get apiSocket(): WebSocket | undefined {
|
||||
return this.channels.get('Universal') ?? this.channels.get('Api');
|
||||
}
|
||||
|
||||
private receive(role: WsRole, data: RawData): void {
|
||||
if (this.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = Buffer.isBuffer(data)
|
||||
? data.toString('utf8')
|
||||
: Array.isArray(data)
|
||||
? Buffer.concat(data).toString('utf8')
|
||||
: Buffer.from(data).toString('utf8');
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
this.fail(new SnowLumaParseError('Reverse peer sent invalid JSON'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isJsonObject(parsed)) {
|
||||
this.fail(new SnowLumaParseError('Reverse peer frame must be a JSON object'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed.post_type === 'string') {
|
||||
if (role === 'Api' || String(parsed.self_id) !== this.selfId) {
|
||||
this.fail(
|
||||
new SnowLumaParseError(
|
||||
'Reverse event account or channel does not match its handshake',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
role === 'Event' ||
|
||||
parsed.echo === undefined ||
|
||||
(parsed.status !== 'ok' && parsed.status !== 'failed')
|
||||
) {
|
||||
this.fail(new SnowLumaParseError('Reverse peer sent an unexpected action response'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit('message', text);
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
// Preserve an error observer until all underlying socket callbacks end.
|
||||
// The owner receives diagnostics independently of SDK close cleanup.
|
||||
try {
|
||||
this.emit('error', error);
|
||||
} finally {
|
||||
this.close(1008, 'invalid OneBot frame');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
@@ -41,6 +50,21 @@ beforeAll(() => {
|
||||
const installed = path.join(consumerRoot, 'node_modules', '@snowluma', 'sdk');
|
||||
mkdirSync(installed, { recursive: true });
|
||||
copyFileSync(path.join(sdkRoot, 'package.json'), path.join(installed, 'package.json'));
|
||||
// Install the declared runtime dependency into this isolated consumer. Node
|
||||
// type declarations belong to the consuming Node project's dev environment.
|
||||
const require = createRequire(import.meta.url);
|
||||
cpSync(
|
||||
path.dirname(require.resolve('ws/package.json')),
|
||||
path.join(consumerRoot, 'node_modules', 'ws'),
|
||||
{ recursive: true },
|
||||
);
|
||||
mkdirSync(path.join(consumerRoot, 'node_modules', '@types'), { recursive: true });
|
||||
symlinkSync(
|
||||
path.dirname(require.resolve('@types/node/package.json')),
|
||||
path.join(consumerRoot, 'node_modules', '@types', 'node'),
|
||||
'dir',
|
||||
);
|
||||
|
||||
copyFileSync(
|
||||
path.join(sdkRoot, 'tests', 'fixtures', 'action-types.mts'),
|
||||
path.join(consumerRoot, 'action-types.mts'),
|
||||
@@ -54,6 +78,46 @@ beforeAll(() => {
|
||||
path.join(consumerRoot, 'messaging-types.mts'),
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
path.join(consumerRoot, 'reverse-types.mts'),
|
||||
`import { text } from '@snowluma/sdk';
|
||||
import { createReverseWebSocketServer, type SnowLumaReverseWebSocketSession } from '@snowluma/sdk/node';
|
||||
const reverse = createReverseWebSocketServer({ port: 0, selfIds: ['10001'] });
|
||||
reverse.on('connection', (bot: SnowLumaReverseWebSocketSession) => {
|
||||
const id: string = bot.selfId;
|
||||
const ready: boolean = bot.canCallActions;
|
||||
void bot.getStatus();
|
||||
bot.onMessage(async (_event, context) => { await context.reply(text(id)); });
|
||||
void ready;
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
path.join(consumerRoot, 'browser-types.mts'),
|
||||
`
|
||||
import { SnowLumaWebSocketClient, text } from '@snowluma/sdk';
|
||||
new SnowLumaWebSocketClient().onMessage(async (_event, context) => { await context.reply(text('hello')); });
|
||||
// @ts-expect-error Node globals must not leak through the browser entry.
|
||||
process.cwd();
|
||||
`,
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(consumerRoot, 'tsconfig.browser.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
target: 'ES2022',
|
||||
lib: ['ES2022', 'DOM'],
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Bundler',
|
||||
types: [],
|
||||
},
|
||||
files: ['browser-types.mts'],
|
||||
}),
|
||||
);
|
||||
|
||||
// Compile fresh output outside the workspace so neither stale dist files nor
|
||||
// Vitest's source resolver can hide a broken published entry point.
|
||||
node([
|
||||
@@ -103,6 +167,25 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
describe('SDK package entry points', () => {
|
||||
it('keeps browser consumers independent of Node server declarations', () => {
|
||||
node([compiler, '-p', path.join(consumerRoot, 'tsconfig.browser.json')]);
|
||||
});
|
||||
|
||||
it('starts the published Node reverse server without workspace resolution', () => {
|
||||
node([
|
||||
'--input-type=module',
|
||||
'--eval',
|
||||
`
|
||||
import assert from 'node:assert/strict';
|
||||
import { createReverseWebSocketServer } from '@snowluma/sdk/node';
|
||||
const server = createReverseWebSocketServer({ port: 0 });
|
||||
await server.listen();
|
||||
assert.ok(server.address.port > 0);
|
||||
await server.close();
|
||||
`,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(entrypoints)('loads %s in native Node without a bundler', (entrypoint) => {
|
||||
node(['--input-type=module', '--eval', `await import(${JSON.stringify(entrypoint)});`]);
|
||||
});
|
||||
@@ -156,6 +239,22 @@ assert.equal(actions.isSnowLumaAction('consumer_extension'), false);
|
||||
path.join(consumerRoot, 'action-types.mts'),
|
||||
path.join(consumerRoot, 'messaging-types.mts'),
|
||||
]);
|
||||
node([
|
||||
compiler,
|
||||
'--noEmit',
|
||||
'--strict',
|
||||
'--target',
|
||||
'ES2022',
|
||||
'--lib',
|
||||
'ES2022,DOM',
|
||||
'--types',
|
||||
'node',
|
||||
'--module',
|
||||
module,
|
||||
'--moduleResolution',
|
||||
resolution,
|
||||
path.join(consumerRoot, 'reverse-types.mts'),
|
||||
]);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { WebSocket } from 'ws';
|
||||
import { SnowLumaConnectionError } from '../src/errors.js';
|
||||
import {
|
||||
createReverseWebSocketServer,
|
||||
type ReverseWebSocketServerOptions,
|
||||
type SnowLumaReverseWebSocketServer,
|
||||
type SnowLumaReverseWebSocketSession,
|
||||
} from '../src/node/index.js';
|
||||
|
||||
const servers: SnowLumaReverseWebSocketServer[] = [];
|
||||
const peers: WebSocket[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
peers.splice(0).forEach((peer) => peer.terminate());
|
||||
await Promise.all(servers.splice(0).map((server) => server.close()));
|
||||
});
|
||||
|
||||
async function start(options: ReverseWebSocketServerOptions = {}) {
|
||||
const server = createReverseWebSocketServer({ port: 0, accessToken: 'test-token', ...options });
|
||||
const errors = vi.fn();
|
||||
server.on('error', errors);
|
||||
servers.push(server);
|
||||
await server.listen();
|
||||
const port = (server.address as AddressInfo).port;
|
||||
|
||||
const connect = (
|
||||
id = '10001',
|
||||
role = 'Universal',
|
||||
extras: { path?: string; headers?: Record<string, string>; autoPong?: boolean } = {},
|
||||
) => {
|
||||
const peer = new WebSocket(`ws://127.0.0.1:${port}${extras.path ?? '/onebot/v11/ws'}`, {
|
||||
headers: {
|
||||
'X-Self-ID': id,
|
||||
'X-Client-Role': role,
|
||||
Authorization: 'Bearer test-token',
|
||||
...extras.headers,
|
||||
},
|
||||
autoPong: extras.autoPong,
|
||||
});
|
||||
peers.push(peer);
|
||||
return peer;
|
||||
};
|
||||
|
||||
const accept = async (id = '10001', role = 'Universal') => {
|
||||
const peer = connect(id, role);
|
||||
await once(peer, 'open');
|
||||
const client = server.getClient(id)!;
|
||||
expect(client).toBeDefined();
|
||||
return { peer, client };
|
||||
};
|
||||
|
||||
return { server, errors, connect, accept };
|
||||
}
|
||||
|
||||
const event = (selfId = 10001) => ({
|
||||
self_id: selfId,
|
||||
time: 1,
|
||||
post_type: 'message',
|
||||
message_type: 'private',
|
||||
sub_type: 'friend',
|
||||
user_id: 20001,
|
||||
message_id: -42,
|
||||
message: [{ type: 'text', data: { text: 'hello' } }],
|
||||
raw_message: 'hello',
|
||||
font: 0,
|
||||
sender: { user_id: 20001, nickname: 'Test' },
|
||||
});
|
||||
|
||||
describe('SDK reverse WebSocket server', () => {
|
||||
it('receives real events and sends actions back over the accepted connection', async () => {
|
||||
const { server, accept, errors } = await start();
|
||||
const connected: SnowLumaReverseWebSocketSession[] = [];
|
||||
server.on('connection', (client) => connected.push(client));
|
||||
const { peer, client } = await accept();
|
||||
expect(connected).toEqual([client]);
|
||||
expect(client.canCallActions).toBe(true);
|
||||
expect(client.canReceiveEvents).toBe(true);
|
||||
const received = new Promise<void>((resolve) =>
|
||||
client.onMessage(async (message, context) => {
|
||||
expect(message.message_id).toBe(-42);
|
||||
await context.reply('reply');
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
const called = once(peer, 'message');
|
||||
peer.send(JSON.stringify(event()));
|
||||
const action = JSON.parse((await called)[0].toString());
|
||||
expect(action).toMatchObject({ action: 'send_private_msg', params: { user_id: 20001 } });
|
||||
peer.send(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { message_id: -43 },
|
||||
echo: action.echo,
|
||||
}),
|
||||
);
|
||||
await received;
|
||||
expect(errors).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts both trailing-slash forms of the configured path', async () => {
|
||||
const { accept } = await start({ path: '/onebot/v11/ws/' });
|
||||
expect((await accept()).client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('isolates equal request echoes between two accounts', async () => {
|
||||
const { accept } = await start();
|
||||
const first = await accept('10001');
|
||||
const second = await accept('10002');
|
||||
const a = first.client.request('get_login_info', {}, { echo: 'same' });
|
||||
const b = second.client.request('get_login_info', {}, { echo: 'same' });
|
||||
await Promise.all([once(first.peer, 'message'), once(second.peer, 'message')]);
|
||||
second.peer.send(
|
||||
JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10002 }, echo: 'same' }),
|
||||
);
|
||||
first.peer.send(
|
||||
JSON.stringify({ status: 'ok', retcode: 0, data: { user_id: 10001 }, echo: 'same' }),
|
||||
);
|
||||
expect((await a).data).toMatchObject({ user_id: 10001 });
|
||||
expect((await b).data).toMatchObject({ user_id: 10002 });
|
||||
});
|
||||
|
||||
it('reports asynchronous handler failures and supports request cancellation and timeout', async () => {
|
||||
const { accept, errors } = await start();
|
||||
const { peer, client } = await accept();
|
||||
client.onMessage(async () => {
|
||||
throw new Error('handler failed');
|
||||
});
|
||||
peer.send(JSON.stringify(event()));
|
||||
await vi.waitFor(() =>
|
||||
expect(errors).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cause: expect.objectContaining({ message: 'handler failed' }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const called = once(peer, 'message');
|
||||
const cancelled = expect(
|
||||
client.request('get_status', {}, { signal: abort.signal }),
|
||||
).rejects.toMatchObject({ name: 'SnowLumaAbortError' });
|
||||
await called;
|
||||
abort.abort();
|
||||
await cancelled;
|
||||
await expect(client.request('get_status', {}, { timeoutMs: 10 })).rejects.toMatchObject({
|
||||
name: 'SnowLumaTimeoutError',
|
||||
});
|
||||
expect(client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('authenticates and validates identity, role, path and duplicate channels before upgrading', async () => {
|
||||
const { connect, accept, server } = await start({ selfIds: ['10001'] });
|
||||
|
||||
const failed = async (peer: WebSocket, status: number) => {
|
||||
const [error] = await once(peer, 'error');
|
||||
expect(error.message).toContain(String(status));
|
||||
};
|
||||
|
||||
await failed(
|
||||
connect('10001', 'Universal', { headers: { Authorization: 'Bearer wrong' } }),
|
||||
401,
|
||||
);
|
||||
await failed(connect('10002'), 403);
|
||||
await failed(connect('', 'Universal'), 400);
|
||||
await failed(connect('10001', 'unknown'), 400);
|
||||
await failed(connect('10001', 'Universal', { path: '/other' }), 404);
|
||||
const first = await accept();
|
||||
await failed(connect(), 409);
|
||||
expect(server.getClient('10001')).toBe(first.client);
|
||||
expect(first.client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('combines separate Event/API channels and routes event replies over the API channel', async () => {
|
||||
const { accept, server } = await start();
|
||||
const eventPeer = await accept('10001', 'Event');
|
||||
expect(eventPeer.client.canCallActions).toBe(false);
|
||||
await expect(eventPeer.client.getStatus()).rejects.toBeInstanceOf(SnowLumaConnectionError);
|
||||
const apiPeer = await accept('10001', 'API');
|
||||
expect(apiPeer.client).toBe(eventPeer.client);
|
||||
expect(apiPeer.client.canCallActions).toBe(true);
|
||||
const done = new Promise<void>((resolve) =>
|
||||
apiPeer.client.onMessage(async (_message, context) => {
|
||||
await context.reply('split-channel reply');
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
const called = once(apiPeer.peer, 'message');
|
||||
eventPeer.peer.send(JSON.stringify(event()));
|
||||
const request = JSON.parse((await called)[0].toString());
|
||||
apiPeer.peer.send(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { message_id: 1 },
|
||||
echo: request.echo,
|
||||
}),
|
||||
);
|
||||
await done;
|
||||
const eventClosed = once(eventPeer.peer, 'close');
|
||||
apiPeer.peer.terminate();
|
||||
await eventClosed;
|
||||
expect(server.clients.size).toBe(0);
|
||||
});
|
||||
|
||||
it('rejects pending actions and waits on disconnect, without replaying them after reconnect', async () => {
|
||||
const { accept, server } = await start();
|
||||
const first = await accept();
|
||||
const called = once(first.peer, 'message');
|
||||
const pending = expect(first.client.getStatus()).rejects.toBeInstanceOf(
|
||||
SnowLumaConnectionError,
|
||||
);
|
||||
const waiting = expect(first.client.waitForMessage()).rejects.toBeInstanceOf(
|
||||
SnowLumaConnectionError,
|
||||
);
|
||||
await called;
|
||||
first.peer.terminate();
|
||||
await Promise.all([pending, waiting]);
|
||||
await vi.waitFor(() => expect(server.clients.size).toBe(0));
|
||||
const replacement = await accept();
|
||||
expect(replacement.client).not.toBe(first.client);
|
||||
await expect(first.client.getStatus()).rejects.toThrow('wait for a new server connection');
|
||||
const seen = vi.fn();
|
||||
replacement.peer.on('message', seen);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['invalid-json', 'wrong-account', 'wrong-role'])(
|
||||
'rejects %s frames and exposes the error without dispatching a false event',
|
||||
async (kind) => {
|
||||
const { accept, errors } = await start();
|
||||
const { peer, client } = await accept(
|
||||
'10001',
|
||||
kind === 'wrong-role' ? 'Api' : 'Universal',
|
||||
);
|
||||
const seen = vi.fn();
|
||||
client.onMessage(seen);
|
||||
const closed = once(peer, 'close');
|
||||
peer.send(
|
||||
kind === 'invalid-json'
|
||||
? '{'
|
||||
: JSON.stringify(event(kind === 'wrong-account' ? 10002 : 10001)),
|
||||
);
|
||||
expect((await closed)[0]).toBe(1008);
|
||||
expect(seen).not.toHaveBeenCalled();
|
||||
expect(errors).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it('enforces inbound and outbound byte limits and connection capacity', async () => {
|
||||
const { accept, connect } = await start({
|
||||
maxPayloadBytes: 256,
|
||||
maxBufferedBytes: 32,
|
||||
maxConnections: 1,
|
||||
});
|
||||
const { peer, client } = await accept();
|
||||
const [rejected] = await once(connect('10002'), 'error');
|
||||
expect(rejected.message).toContain('503');
|
||||
await expect(client.getStatus()).rejects.toMatchObject({
|
||||
cause: { message: 'Reverse WebSocket outbound buffer limit exceeded' },
|
||||
});
|
||||
const closed = once(peer, 'close');
|
||||
peer.send('x'.repeat(257));
|
||||
expect((await closed)[0]).toBe(1009);
|
||||
});
|
||||
|
||||
it('reaps a silent peer and allows a new connection after the old session closes', async () => {
|
||||
const { connect, accept, server } = await start({ heartbeatIntervalMs: 15 });
|
||||
const silent = connect('10001', 'Universal', { autoPong: false });
|
||||
await once(silent, 'open');
|
||||
await once(silent, 'close');
|
||||
await vi.waitFor(() => expect(server.clients.size).toBe(0));
|
||||
expect((await accept()).client.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves an externally owned HTTP server running after closing SDK connections', async () => {
|
||||
const http = createServer((_req, res) => res.end('still serving'));
|
||||
http.listen(0, '127.0.0.1');
|
||||
await once(http, 'listening');
|
||||
const server = createReverseWebSocketServer({ server: http, accessToken: 'token' });
|
||||
servers.push(server);
|
||||
server.on('error', (error) => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
await server.listen();
|
||||
const port = (http.address() as AddressInfo).port;
|
||||
const peer = new WebSocket(`ws://127.0.0.1:${port}/onebot/v11/ws?access_token=token`, {
|
||||
headers: { 'X-Self-ID': '10001', 'X-Client-Role': 'Universal' },
|
||||
});
|
||||
peers.push(peer);
|
||||
await once(peer, 'open');
|
||||
await server.close();
|
||||
expect(await (await fetch(`http://127.0.0.1:${port}/health`)).text()).toBe(
|
||||
'still serving',
|
||||
);
|
||||
expect(server.clients.size).toBe(0);
|
||||
} finally {
|
||||
await server.close();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
http.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a failed bind, then supports retry and repeated close', async () => {
|
||||
const occupied = createServer();
|
||||
occupied.listen(0, '127.0.0.1');
|
||||
await once(occupied, 'listening');
|
||||
const server = createReverseWebSocketServer({
|
||||
port: (occupied.address() as AddressInfo).port,
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
try {
|
||||
await expect(server.listen()).rejects.toMatchObject({ code: 'EADDRINUSE' });
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => occupied.close(() => resolve()));
|
||||
}
|
||||
|
||||
await server.listen();
|
||||
await Promise.all([server.close(), server.close()]);
|
||||
await server.listen();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
Generated
+7
@@ -345,10 +345,17 @@ importers:
|
||||
packages/runtime: {}
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
ws:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: 'catalog:'
|
||||
version: 24.19.2
|
||||
'@types/ws':
|
||||
specifier: 8.18.2
|
||||
version: 8.18.2
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.2
|
||||
|
||||
Reference in New Issue
Block a user