SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { StreamTransportClosedError } from '../streaming';
|
||||
import type { WebSocket } from '@snowluma/websocket';
|
||||
import type { IncomingMessage } from 'http';
|
||||
|
||||
/**
|
||||
* Appended to a 1009 (frame-over-cap) log line. Shared so the client and server
|
||||
* adapters cannot drift apart on the advice they give.
|
||||
*/
|
||||
export const MAX_PAYLOAD_HINT =
|
||||
"Raise this adapter's maxPayloadBytes, or have the peer send a smaller frame (a URL/file reference or SILK instead of inline PCM)";
|
||||
|
||||
export function isAuthorized(
|
||||
request: IncomingMessage,
|
||||
token: string,
|
||||
allowRawToken = false,
|
||||
): boolean {
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const auth = request.headers.authorization ?? '';
|
||||
|
||||
if (auth === `Bearer ${token}` || (allowRawToken && auth === token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||
|
||||
if (url.searchParams.get('access_token') === token) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed URLs */
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Convert anything ws can hand us into a UTF-8 string. */
|
||||
export function rawDataToString(
|
||||
raw: Buffer | string | ArrayBuffer | ArrayBufferView | Buffer[],
|
||||
): string {
|
||||
if (typeof raw === 'string') {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(raw)) {
|
||||
return raw.toString('utf8');
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return Buffer.concat(raw).toString('utf8');
|
||||
}
|
||||
|
||||
if (raw instanceof ArrayBuffer) {
|
||||
return Buffer.from(new Uint8Array(raw)).toString('utf8');
|
||||
}
|
||||
|
||||
return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
|
||||
}
|
||||
|
||||
export function safeSend(socket: WebSocket, payload: string, onError?: (err: Error) => void): void {
|
||||
if (socket.readyState !== 1 /* WebSocket.OPEN */) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(payload, (error?: Error | null) => {
|
||||
if (error && onError) {
|
||||
onError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Backpressure-aware send: resolves only once `ws` has flushed `payload` to
|
||||
* the socket (the `send` callback fires post-write), so a streaming producer
|
||||
* that awaits this won't outrun a slow client and balloon the send buffer.
|
||||
* A closed socket or failed write rejects with the shared transport sentinel,
|
||||
* allowing the action boundary to classify an in-progress stream cancellation. */
|
||||
export function safeSendAsync(socket: WebSocket, payload: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (socket.readyState !== 1 /* WebSocket.OPEN */) {
|
||||
reject(new StreamTransportClosedError('stream client disconnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.send(payload, (error?: Error | null) => {
|
||||
if (error) {
|
||||
reject(new StreamTransportClosedError('stream client disconnected'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
} catch {
|
||||
reject(new StreamTransportClosedError('stream client disconnected'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function safeClose(socket: WebSocket, code = 1000, reason = 'normal'): void {
|
||||
if (socket.readyState === 3 /* CLOSED */ || socket.readyState === 2 /* CLOSING */) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.close(code, reason);
|
||||
}
|
||||
|
||||
/** RFC6455 transport-level keepalive for a reverse-WS connection.
|
||||
*
|
||||
* The OneBot app-level `meta_event` heartbeat is a one-way outbound push, so it
|
||||
* cannot detect a connection that is writable but no longer readable — a
|
||||
* half-open link left behind when the peer restarts behind a proxy/NAT. In that
|
||||
* state events keep flowing out while inbound action frames silently never
|
||||
* arrive, and only a full restart recovers it (issue #208).
|
||||
*
|
||||
* This pings every `intervalMs` and counts consecutive silent intervals. ANY
|
||||
* inbound activity — a pong OR a message — resets the counter, so a connection
|
||||
* carrying real traffic is never reaped. Death is declared once `maxMissed`
|
||||
* pings have gone unanswered, i.e. after roughly `(maxMissed + 1) * intervalMs`
|
||||
* of total silence (the counter is checked at the top of each tick, so the
|
||||
* tick that observes the threshold is one interval past the last ping). The
|
||||
* tolerance plus a conservative interval keeps the false-positive rate near
|
||||
* zero: a single GC pause, event-loop stall, or transient latency spike is
|
||||
* absorbed (Node fires an overdue interval once, not N catch-up times), and
|
||||
* death is declared only on genuine sustained silence.
|
||||
*
|
||||
* Returns an idempotent stop function; the timer is `unref`'d so it never keeps
|
||||
* the process alive. */
|
||||
export function startHeartbeat(
|
||||
socket: WebSocket,
|
||||
opts: { intervalMs: number; maxMissed: number },
|
||||
onDead: () => void,
|
||||
): () => void {
|
||||
let missed = 0;
|
||||
let stopped = false;
|
||||
|
||||
const onActivity = (): void => {
|
||||
missed = 0;
|
||||
};
|
||||
|
||||
socket.on('pong', onActivity);
|
||||
socket.on('message', onActivity);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (missed >= opts.maxMissed) {
|
||||
// `maxMissed` consecutive intervals with zero inbound traffic — the read
|
||||
// direction is dead. Stop first so onDead()'s terminate→close→reconnect
|
||||
// can't re-enter this tick.
|
||||
stop();
|
||||
onDead();
|
||||
return;
|
||||
}
|
||||
|
||||
missed += 1;
|
||||
|
||||
try {
|
||||
socket.ping();
|
||||
} catch {
|
||||
// Ping on an already-dead socket — treat as dead immediately.
|
||||
stop();
|
||||
onDead();
|
||||
}
|
||||
}, opts.intervalMs);
|
||||
timer.unref();
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
socket.off('pong', onActivity);
|
||||
socket.off('message', onActivity);
|
||||
}
|
||||
|
||||
return stop;
|
||||
}
|
||||
|
||||
export function normalizePath(pathValue: string | undefined): string {
|
||||
const path = (pathValue ?? '/').trim() || '/';
|
||||
|
||||
if (path === '/') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path;
|
||||
}
|
||||
|
||||
export function parseRequestPath(urlValue: string): string {
|
||||
const queryIndex = urlValue.indexOf('?');
|
||||
return queryIndex === -1 ? urlValue : urlValue.slice(0, queryIndex);
|
||||
}
|
||||
Reference in new issue
Block a user