@kuralle-syrinx/server-websocket 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/package.json +31 -0
- package/src/admission-control.test.ts +247 -0
- package/src/browser-opus.test.ts +41 -0
- package/src/browser-opus.ts +59 -0
- package/src/browser-pacing.test.ts +440 -0
- package/src/edge-twilio.test.ts +215 -0
- package/src/edge-twilio.ts +285 -0
- package/src/edge.test.ts +272 -0
- package/src/edge.ts +652 -0
- package/src/graceful-drain.test.ts +306 -0
- package/src/inbound-audio.ts +102 -0
- package/src/index.test.ts +2071 -0
- package/src/index.ts +891 -0
- package/src/json-message.ts +39 -0
- package/src/outbound-playout-pipeline.test.ts +208 -0
- package/src/outbound-playout-pipeline.ts +167 -0
- package/src/paced-playout.test.ts +247 -0
- package/src/paced-playout.ts +161 -0
- package/src/playout-progress.test.ts +56 -0
- package/src/playout-progress.ts +67 -0
- package/src/session-store.test.ts +274 -0
- package/src/session-store.ts +123 -0
- package/src/smartpbx.test.ts +967 -0
- package/src/smartpbx.ts +531 -0
- package/src/telnyx.test.ts +1413 -0
- package/src/telnyx.ts +708 -0
- package/src/test-helpers.ts +264 -0
- package/src/transport-helpers.ts +78 -0
- package/src/transport-host.test.ts +51 -0
- package/src/transport-host.ts +213 -0
- package/src/turn-metrics.test.ts +327 -0
- package/src/turn-metrics.ts +193 -0
- package/src/twilio.test.ts +1275 -0
- package/src/twilio.ts +599 -0
- package/src/websocket-close.test.ts +63 -0
- package/src/websocket-close.ts +68 -0
- package/src/websocket-lifecycle.test.ts +49 -0
- package/src/websocket-lifecycle.ts +105 -0
- package/src/websocket-upgrade.ts +102 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TERMINATE_AFTER_MS = 250;
|
|
6
|
+
|
|
7
|
+
export function closeWebSocketWithFallback(
|
|
8
|
+
socket: WebSocket,
|
|
9
|
+
code: number,
|
|
10
|
+
reason: string,
|
|
11
|
+
terminateAfterMs = DEFAULT_TERMINATE_AFTER_MS,
|
|
12
|
+
): void {
|
|
13
|
+
if (socket.readyState === WebSocket.CLOSED || socket.readyState === WebSocket.CLOSING) return;
|
|
14
|
+
|
|
15
|
+
socket.close(code, reason);
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
if (socket.readyState !== WebSocket.CLOSED) {
|
|
18
|
+
socket.terminate();
|
|
19
|
+
}
|
|
20
|
+
}, terminateAfterMs);
|
|
21
|
+
timer.unref?.();
|
|
22
|
+
socket.once("close", () => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function waitForWebSocketClose(socket: WebSocket, fallbackMs: number): Promise<void> {
|
|
28
|
+
if (socket.readyState === WebSocket.CLOSED) return Promise.resolve();
|
|
29
|
+
return new Promise<void>((resolve) => {
|
|
30
|
+
let settled = false;
|
|
31
|
+
const settle = (): void => {
|
|
32
|
+
if (settled) return;
|
|
33
|
+
settled = true;
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
resolve();
|
|
36
|
+
};
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
|
|
39
|
+
socket.terminate();
|
|
40
|
+
}
|
|
41
|
+
settle();
|
|
42
|
+
}, fallbackMs);
|
|
43
|
+
timer.unref?.();
|
|
44
|
+
socket.once("close", settle);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Serialize `value` to JSON and send it, enforcing the per-socket send-buffer
|
|
50
|
+
* cap. Returns false (and sends nothing) if the socket is not OPEN. If the send
|
|
51
|
+
* would push `bufferedAmount` past `maxBufferedAmountBytes`, the socket is closed
|
|
52
|
+
* 1013 (slow consumer / try-again-later) and false is returned. Canonical safe
|
|
53
|
+
* downlink-JSON write shared by every carrier adapter.
|
|
54
|
+
*/
|
|
55
|
+
export function sendJsonCapped(
|
|
56
|
+
socket: WebSocket,
|
|
57
|
+
value: unknown,
|
|
58
|
+
maxBufferedAmountBytes: number,
|
|
59
|
+
): boolean {
|
|
60
|
+
if (socket.readyState !== WebSocket.OPEN) return false;
|
|
61
|
+
const data = JSON.stringify(value);
|
|
62
|
+
if (socket.bufferedAmount + Buffer.byteLength(data, "utf8") > maxBufferedAmountBytes) {
|
|
63
|
+
closeWebSocketWithFallback(socket, 1013, "websocket send buffer exceeded");
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
socket.send(data);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
|
|
5
|
+
|
|
6
|
+
describe("installGracefulShutdown", () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
process.removeAllListeners("SIGTERM");
|
|
9
|
+
process.removeAllListeners("SIGINT");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("drains the server gracefully on a termination signal", async () => {
|
|
13
|
+
const closeCalls: Array<{ graceful?: boolean; drainDeadlineMs?: number }> = [];
|
|
14
|
+
const server: GracefulClosable = {
|
|
15
|
+
close: async (opts) => {
|
|
16
|
+
closeCalls.push(opts ?? {});
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const onClosed = vi.fn();
|
|
20
|
+
installGracefulShutdown(server, { drainDeadlineMs: 7000, onClosed });
|
|
21
|
+
|
|
22
|
+
process.emit("SIGTERM");
|
|
23
|
+
await vi.waitFor(() => expect(onClosed).toHaveBeenCalledTimes(1));
|
|
24
|
+
|
|
25
|
+
expect(closeCalls).toEqual([{ graceful: true, drainDeadlineMs: 7000 }]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("is idempotent — a second signal during shutdown does not double-close", async () => {
|
|
29
|
+
let closeCount = 0;
|
|
30
|
+
const server: GracefulClosable = { close: async () => { closeCount += 1; } };
|
|
31
|
+
installGracefulShutdown(server, { signals: ["SIGTERM"] });
|
|
32
|
+
|
|
33
|
+
process.emit("SIGTERM");
|
|
34
|
+
process.emit("SIGTERM");
|
|
35
|
+
await vi.waitFor(() => expect(closeCount).toBe(1));
|
|
36
|
+
expect(closeCount).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("the disposer removes the signal handler", async () => {
|
|
40
|
+
let closeCount = 0;
|
|
41
|
+
const server: GracefulClosable = { close: async () => { closeCount += 1; } };
|
|
42
|
+
const dispose = installGracefulShutdown(server, { signals: ["SIGINT"] });
|
|
43
|
+
dispose();
|
|
44
|
+
|
|
45
|
+
process.emit("SIGINT");
|
|
46
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
47
|
+
expect(closeCount).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { WebSocket } from "ws";
|
|
4
|
+
import { closeWebSocketWithFallback } from "./websocket-close.js";
|
|
5
|
+
|
|
6
|
+
export class WebSocketStartupTimeoutError extends Error {
|
|
7
|
+
constructor(timeoutMs: number) {
|
|
8
|
+
super(`websocket session startup exceeded ${String(timeoutMs)}ms`);
|
|
9
|
+
this.name = "WebSocketStartupTimeoutError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function withWebSocketStartupTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
14
|
+
if (timeoutMs <= 0) return await promise;
|
|
15
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
16
|
+
try {
|
|
17
|
+
return await Promise.race([
|
|
18
|
+
promise,
|
|
19
|
+
new Promise<never>((_resolve, reject) => {
|
|
20
|
+
timeout = setTimeout(() => reject(new WebSocketStartupTimeoutError(timeoutMs)), timeoutMs);
|
|
21
|
+
}),
|
|
22
|
+
]);
|
|
23
|
+
} finally {
|
|
24
|
+
if (timeout) clearTimeout(timeout);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function startWebSocketHeartbeat(
|
|
29
|
+
socket: WebSocket,
|
|
30
|
+
heartbeatIntervalMs: number,
|
|
31
|
+
disposers: Array<() => void>,
|
|
32
|
+
): void {
|
|
33
|
+
if (heartbeatIntervalMs <= 0) return;
|
|
34
|
+
let alive = true;
|
|
35
|
+
const onPong = () => {
|
|
36
|
+
alive = true;
|
|
37
|
+
};
|
|
38
|
+
socket.on("pong", onPong);
|
|
39
|
+
const interval = setInterval(() => {
|
|
40
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
41
|
+
if (!alive) {
|
|
42
|
+
socket.terminate();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
alive = false;
|
|
46
|
+
socket.ping();
|
|
47
|
+
}, heartbeatIntervalMs);
|
|
48
|
+
disposers.push(() => {
|
|
49
|
+
clearInterval(interval);
|
|
50
|
+
socket.off("pong", onPong);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal closable shape: a voice websocket server (or any server) that drains on graceful close. */
|
|
55
|
+
export interface GracefulClosable {
|
|
56
|
+
close(opts?: { graceful?: boolean; drainDeadlineMs?: number }): Promise<void>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Wire process termination signals to the server's graceful drain so a deploy/scale-down
|
|
61
|
+
* drains active calls instead of killing them (Hard Rule: "drain, don't kill"). Idempotent
|
|
62
|
+
* (a second signal during shutdown is ignored). Returns a disposer that removes the handlers.
|
|
63
|
+
*
|
|
64
|
+
* Production hosts call this once after creating the server:
|
|
65
|
+
* installGracefulShutdown(server, { drainDeadlineMs: 10_000, onClosed: () => process.exit(0) });
|
|
66
|
+
*/
|
|
67
|
+
export function installGracefulShutdown(
|
|
68
|
+
server: GracefulClosable,
|
|
69
|
+
opts?: {
|
|
70
|
+
readonly drainDeadlineMs?: number;
|
|
71
|
+
readonly signals?: readonly NodeJS.Signals[];
|
|
72
|
+
readonly onClosed?: () => void;
|
|
73
|
+
readonly onError?: (err: unknown) => void;
|
|
74
|
+
},
|
|
75
|
+
): () => void {
|
|
76
|
+
const drainDeadlineMs = opts?.drainDeadlineMs ?? 10_000;
|
|
77
|
+
const signals = opts?.signals ?? (["SIGTERM", "SIGINT"] as const);
|
|
78
|
+
let shuttingDown = false;
|
|
79
|
+
const handler = (): void => {
|
|
80
|
+
if (shuttingDown) return;
|
|
81
|
+
shuttingDown = true;
|
|
82
|
+
server
|
|
83
|
+
.close({ graceful: true, drainDeadlineMs })
|
|
84
|
+
.then(() => opts?.onClosed?.())
|
|
85
|
+
.catch((err: unknown) => (opts?.onError ? opts.onError(err) : opts?.onClosed?.()));
|
|
86
|
+
};
|
|
87
|
+
for (const signal of signals) process.once(signal, handler);
|
|
88
|
+
return () => {
|
|
89
|
+
for (const signal of signals) process.removeListener(signal, handler);
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function startWebSocketMaxSessionDuration(
|
|
94
|
+
socket: WebSocket,
|
|
95
|
+
maxSessionDurationMs: number,
|
|
96
|
+
disposers: Array<() => void>,
|
|
97
|
+
onTimeout?: () => void,
|
|
98
|
+
): void {
|
|
99
|
+
if (maxSessionDurationMs <= 0) return;
|
|
100
|
+
const timeout = setTimeout(() => {
|
|
101
|
+
onTimeout?.();
|
|
102
|
+
closeWebSocketWithFallback(socket, 1000, "websocket max session duration exceeded");
|
|
103
|
+
}, maxSessionDurationMs);
|
|
104
|
+
disposers.push(() => clearTimeout(timeout));
|
|
105
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage, Server as HttpServer } from "node:http";
|
|
4
|
+
import type { Socket } from "node:net";
|
|
5
|
+
import { WebSocketServer } from "ws";
|
|
6
|
+
import {
|
|
7
|
+
rejectWebSocketAdmission,
|
|
8
|
+
type TransportAdmissionOptions,
|
|
9
|
+
} from "./transport-host.js";
|
|
10
|
+
|
|
11
|
+
export interface RoutedWebSocketServer {
|
|
12
|
+
readonly wsServer: WebSocketServer;
|
|
13
|
+
readonly detach: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type UpgradeHandler = (
|
|
17
|
+
request: IncomingMessage,
|
|
18
|
+
socket: Socket,
|
|
19
|
+
head: Buffer,
|
|
20
|
+
) => void;
|
|
21
|
+
|
|
22
|
+
interface HttpUpgradeRouter {
|
|
23
|
+
readonly handlers: Map<string, UpgradeHandler>;
|
|
24
|
+
readonly servers: Set<WebSocketServer>;
|
|
25
|
+
readonly listener: (request: IncomingMessage, socket: Socket, head: Buffer) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const routers = new WeakMap<HttpServer, HttpUpgradeRouter>();
|
|
29
|
+
|
|
30
|
+
function getOrCreateRouter(httpServer: HttpServer): HttpUpgradeRouter {
|
|
31
|
+
const existing = routers.get(httpServer);
|
|
32
|
+
if (existing) return existing;
|
|
33
|
+
|
|
34
|
+
const handlers = new Map<string, UpgradeHandler>();
|
|
35
|
+
const listener = (request: IncomingMessage, socket: Socket, head: Buffer): void => {
|
|
36
|
+
const pathname = requestPathname(request);
|
|
37
|
+
const handler = handlers.get(pathname);
|
|
38
|
+
if (handler) {
|
|
39
|
+
handler(request, socket, head);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (httpServer.listenerCount("upgrade") > 1) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
socket.destroy();
|
|
46
|
+
};
|
|
47
|
+
httpServer.on("upgrade", listener);
|
|
48
|
+
const router: HttpUpgradeRouter = { handlers, servers: new Set(), listener };
|
|
49
|
+
routers.set(httpServer, router);
|
|
50
|
+
return router;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function requestPathname(request: IncomingMessage): string {
|
|
54
|
+
try {
|
|
55
|
+
return new URL(request.url ?? "/", "http://localhost").pathname;
|
|
56
|
+
} catch {
|
|
57
|
+
return "/";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createRoutedWebSocketServer(
|
|
62
|
+
httpServer: HttpServer,
|
|
63
|
+
path: string,
|
|
64
|
+
admission?: TransportAdmissionOptions,
|
|
65
|
+
): RoutedWebSocketServer {
|
|
66
|
+
const wsServer = new WebSocketServer({
|
|
67
|
+
noServer: true,
|
|
68
|
+
perMessageDeflate: false,
|
|
69
|
+
});
|
|
70
|
+
const router = getOrCreateRouter(httpServer);
|
|
71
|
+
const handler: UpgradeHandler = (request, socket, head) => {
|
|
72
|
+
const maxConcurrentSessions = admission?.maxConcurrentSessions;
|
|
73
|
+
const scope = admission?.maxConcurrentSessionsScope ?? "path";
|
|
74
|
+
const activeSessions = scope === "server"
|
|
75
|
+
? [...router.servers].reduce((sum, server) => sum + server.clients.size, 0)
|
|
76
|
+
: wsServer.clients.size;
|
|
77
|
+
if (
|
|
78
|
+
maxConcurrentSessions !== undefined
|
|
79
|
+
&& activeSessions >= maxConcurrentSessions
|
|
80
|
+
) {
|
|
81
|
+
admission?.onAdmissionRejected?.();
|
|
82
|
+
rejectWebSocketAdmission(wsServer, request, socket, head);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
wsServer.handleUpgrade(request, socket, head, (websocket) => {
|
|
86
|
+
wsServer.emit("connection", websocket, request);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
router.handlers.set(path, handler);
|
|
90
|
+
router.servers.add(wsServer);
|
|
91
|
+
return {
|
|
92
|
+
wsServer,
|
|
93
|
+
detach: () => {
|
|
94
|
+
router.handlers.delete(path);
|
|
95
|
+
router.servers.delete(wsServer);
|
|
96
|
+
if (router.handlers.size === 0) {
|
|
97
|
+
httpServer.off("upgrade", router.listener);
|
|
98
|
+
routers.delete(httpServer);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noUncheckedIndexedAccess": true,
|
|
10
|
+
"noImplicitReturns": true,
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"rootDir": "./src",
|
|
16
|
+
"esModuleInterop": true,
|
|
17
|
+
"skipLibCheck": true,
|
|
18
|
+
"forceConsistentCasingInFileNames": true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*.ts"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|