Add WebSocket transport

This commit is contained in:
2026-07-27 09:32:54 +00:00
parent 69fc23a4c1
commit 5bc79c47e8
5 changed files with 447 additions and 1 deletions
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { HonoSSEStream } from "../../../src/services/stream/hono-sse-stream.js";
import { HttpRequestStream } from "../../../src/services/stream/http-request-stream.js";
import { WSStream } from "../../../src/services/stream/ws-stream.js";
describe("stream lifecycle observers", () => {
it("buffers exactly one normal HTTP response", async () => {
const stream = new HttpRequestStream();
await stream.send({
type: "response",
statusCode: 200,
body: { ok: true },
});
expect(stream.getResponse()).toEqual({
type: "response",
statusCode: 200,
body: { ok: true },
});
await expect(
stream.send({
type: "response",
statusCode: 200,
body: { second: true },
}),
).rejects.toThrow("only send one response");
});
it("notifies WebSocket observers registered after remote closure", () => {
const stream = new WSStream({
send: vi.fn(),
close: vi.fn(),
readyState: 1,
});
const onClose = vi.fn();
stream.markClosed();
stream.onClose(onClose);
expect(onClose).toHaveBeenCalledOnce();
});
it("notifies SSE observers registered after local closure", () => {
const streamApi = {
writeSSE: vi.fn(),
close: vi.fn(),
};
const stream = new HonoSSEStream(
streamApi as unknown as ConstructorParameters<typeof HonoSSEStream>[0],
);
const onClose = vi.fn();
stream.close();
stream.onClose(onClose);
stream.close();
expect(streamApi.close).toHaveBeenCalledOnce();
expect(onClose).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { WebSocketServer } from "ws";
import { ApplicationRouter } from "../../../src/services/router.js";
import {
WsTransportRouter,
} from "../../../src/services/transport/ws-transport.js";
import { Logger } from "../../../src/utils/logger.js";
import { toExtendedJson } from "@xo-cash/utils";
describe("WebSocket request decoding", () => {
it("decodes the minimal route-agnostic envelope and Extended JSON body", async () => {
await expect(
WsTransportRouter.decodeWebSocketRequest(
toExtendedJson({
id: "request-1",
path: "/data/write",
body: { value: new Uint8Array([1, 2, 3]) },
}),
),
).resolves.toEqual({
requestId: "request-1",
path: "/data/write",
body: { value: new Uint8Array([1, 2, 3]) },
});
});
it.each([
"{}",
'{"path":42}',
'{"path":"/data/get","id":1}',
'{"path":"/data/get","method":"POST"}',
])("rejects an invalid envelope: %s", async (payload) => {
await expect(WsTransportRouter.decodeWebSocketRequest(payload)).rejects.toBeInstanceOf(
z.ZodError,
);
});
it("rejects malformed JSON", async () => {
await expect(WsTransportRouter.decodeWebSocketRequest("{")).rejects.toMatchObject({
statusCode: 400,
message: "Invalid JSON in WebSocket message",
});
});
});
describe("WsTransportRouter payload limits", () => {
it("configures Hono's ws server with the requested maxPayload", async () => {
const debug = new Logger("ws-transport-test");
const router = await ApplicationRouter.create([]);
const transport = new WsTransportRouter(router, debug, 1024);
const wsServer = transport.websocketServer as unknown as WebSocketServer;
expect(wsServer.options.maxPayload).toBe(1024);
await transport.stop();
});
});