From 71e24caca50d937e2020a40d71f609ffcbf3d4a1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 00:27:07 -0700 Subject: [PATCH 1/2] Allow configuring the WebSocket server in NodeHttpServer and BunHttpServer Co-Authored-By: Claude Fable 5 --- .changeset/http-server-websocket-options.md | 24 +++++++++ packages/platform-bun/src/BunHttpServer.ts | 24 +++++++++ packages/platform-node/src/NodeHttpServer.ts | 45 +++++++++------- .../platform-node/test/NodeHttpServer.test.ts | 51 +++++++++++++++++++ 4 files changed, 125 insertions(+), 19 deletions(-) create mode 100644 .changeset/http-server-websocket-options.md diff --git a/.changeset/http-server-websocket-options.md b/.changeset/http-server-websocket-options.md new file mode 100644 index 00000000000..3d0f3363603 --- /dev/null +++ b/.changeset/http-server-websocket-options.md @@ -0,0 +1,24 @@ +--- +"@effect/platform-node": patch +"@effect/platform-bun": patch +--- + +Allow configuring the WebSocket server in `NodeHttpServer` and `BunHttpServer`. + +The HTTP server layers previously hardcoded their WebSocket wiring — `NodeHttpServer` always constructed `new WebSocketServer({ noServer: true })`, and `BunHttpServer` overwrote any `websocket` key in the serve options with its own handlers. There was no way to enable `permessage-deflate` compression (which browsers offer on every connection) or tune payload limits. + +Both servers now accept a `websocket` option that is forwarded to the underlying implementation, with the wiring/lifecycle options the server manages excluded from the type: + +```ts +// Node: forwarded to the `ws` WebSocketServer +NodeHttpServer.layer(() => createServer(), { + port: 3000, + websocket: { perMessageDeflate: true } +}) + +// Bun: merged into Bun.serve's websocket handler +BunHttpServer.layer({ + port: 3000, + websocket: { perMessageDeflate: true } +}) +``` diff --git a/packages/platform-bun/src/BunHttpServer.ts b/packages/platform-bun/src/BunHttpServer.ts index 9f2ac3dfc01..aa3c14d5760 100644 --- a/packages/platform-bun/src/BunHttpServer.ts +++ b/packages/platform-bun/src/BunHttpServer.ts @@ -66,6 +66,25 @@ export type ServeOptions = ) & { readonly routes?: Bun.Serve.Routes } +/** + * WebSocket tuning options forwarded to `Bun.serve`'s `websocket` handler. + * + * **Details** + * + * The lifecycle handlers (`open`, `message`, `close`, ...) are managed by the + * server and cannot be overridden; everything else — such as + * `perMessageDeflate` compression, payload limits, and idle timeouts — passes + * through, e.g. + * `BunHttpServer.layer({ port: 3000, websocket: { perMessageDeflate: true } })`. + * + * @category options + * @since 4.0.0 + */ +export type WebSocketOptions = Omit< + Bun.WebSocketHandler, + "open" | "message" | "close" | "drain" | "ping" | "pong" | "data" +> + /** * Creates a scoped Bun `HttpServer` from `Bun.serve` options, stopping the server on scope finalization with optional graceful shutdown settings. * @@ -77,6 +96,7 @@ export const make = Effect.fnUntraced( options: ServeOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: WebSocketOptions | undefined } ) { const scope = yield* Effect.scope @@ -90,6 +110,7 @@ export const make = Effect.fnUntraced( ...options as ServeOptions, fetch: handlerStack[0], websocket: { + ...options.websocket, open(ws) { Deferred.doneUnsafe(ws.data.deferred, Exit.succeed(ws)) }, @@ -233,6 +254,7 @@ export const layerServer: ( options: ServeOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: WebSocketOptions | undefined } ) => Layer.Layer = flow(make, Layer.effect(Server.HttpServer)) as any @@ -262,6 +284,7 @@ export const layer = ( options: ServeOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: WebSocketOptions | undefined } ): Layer.Layer< | Server.HttpServer @@ -296,6 +319,7 @@ export const layerConfig = ( ServeOptions & { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: WebSocketOptions | undefined } > ): Layer.Layer< diff --git a/packages/platform-node/src/NodeHttpServer.ts b/packages/platform-node/src/NodeHttpServer.ts index e18cd931f17..120f0e4a779 100644 --- a/packages/platform-node/src/NodeHttpServer.ts +++ b/packages/platform-node/src/NodeHttpServer.ts @@ -62,6 +62,27 @@ import * as NodeMultipart from "./NodeMultipart.ts" import * as NodeServices from "./NodeServices.ts" import { NodeWS } from "./NodeSocket.ts" +/** + * Options accepted by the Node `HttpServer` constructors and layers. + * + * **Details** + * + * `websocket` is forwarded to the underlying `ws` `WebSocketServer`, minus the + * wiring options the server manages itself. Use it to enable + * `permessage-deflate` compression or tune payload limits, e.g. + * `NodeHttpServer.layer(() => createServer(), { port: 3000, websocket: { perMessageDeflate: true } })`. + * + * @category options + * @since 4.0.0 + */ +export interface Options extends Net.ListenOptions { + readonly disablePreemptiveShutdown?: boolean | undefined + readonly gracefulShutdownTimeout?: Duration.Input | undefined + readonly websocket?: + | Omit + | undefined +} + /** * Creates a scoped `HttpServer` from a Node `http.Server`, starts listening * with the supplied options, registers request and upgrade handling, and closes @@ -72,10 +93,7 @@ import { NodeWS } from "./NodeSocket.ts" */ export const make = Effect.fnUntraced(function*( evaluate: LazyArg, - options: Net.ListenOptions & { - readonly disablePreemptiveShutdown?: boolean | undefined - readonly gracefulShutdownTimeout?: Duration.Input | undefined - } + options: Options ) { const scope = yield* Effect.scope const server = evaluate() @@ -116,7 +134,7 @@ export const make = Effect.fnUntraced(function*( const address = server.address()! const wss = yield* Effect.acquireRelease( - Effect.sync(() => new NodeWS.WebSocketServer({ noServer: true })), + Effect.sync(() => new NodeWS.WebSocketServer({ ...options.websocket, noServer: true })), (wss) => Effect.callback((resume) => { wss.close(() => resume(Effect.void)) @@ -397,10 +415,7 @@ class ServerRequestImpl extends NodeHttpIncomingMessage impleme */ export const layerServer: ( evaluate: LazyArg>, - options: Net.ListenOptions & { - readonly disablePreemptiveShutdown?: boolean | undefined - readonly gracefulShutdownTimeout?: Duration.Input | undefined - } + options: Options ) => Layer.Layer = flow(make, Layer.effect(HttpServer.HttpServer)) /** @@ -427,10 +442,7 @@ export const layerHttpServices: Layer.Layer< */ export const layer = ( evaluate: LazyArg, - options: Net.ListenOptions & { - readonly disablePreemptiveShutdown?: boolean | undefined - readonly gracefulShutdownTimeout?: Duration.Input | undefined - } + options: Options ): Layer.Layer< HttpServer.HttpServer | NodeServices.NodeServices | HttpPlatform.HttpPlatform | Etag.Generator, ServeError @@ -450,12 +462,7 @@ export const layer = ( */ export const layerConfig = ( evaluate: LazyArg, - options: Config.Wrap< - Net.ListenOptions & { - readonly disablePreemptiveShutdown?: boolean | undefined - readonly gracefulShutdownTimeout?: Duration.Input | undefined - } - > + options: Config.Wrap ): Layer.Layer< HttpServer.HttpServer | NodeServices.NodeServices | HttpPlatform.HttpPlatform | Etag.Generator, ServeError | Config.ConfigError diff --git a/packages/platform-node/test/NodeHttpServer.test.ts b/packages/platform-node/test/NodeHttpServer.test.ts index 031025b6897..3ac1698b3b4 100644 --- a/packages/platform-node/test/NodeHttpServer.test.ts +++ b/packages/platform-node/test/NodeHttpServer.test.ts @@ -1,5 +1,6 @@ /** @effect-diagnostics preferSchemaOverJson:skip-file */ import { NodeHttpServer } from "@effect/platform-node" +import { NodeWS } from "@effect/platform-node/NodeSocket" import { assert, describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import * as Duration from "effect/Duration" @@ -673,8 +674,58 @@ describe("HttpServer", () => { ) expect(root).toEqual("root") }).pipe(Effect.provide(NodeHttpServer.layerTest))) + + it.effect("websocket options are forwarded to the WebSocketServer", () => + Effect.gen(function*() { + yield* HttpRouter.add( + "GET", + "/ws", + Effect.gen(function*() { + const request = yield* HttpServerRequest.HttpServerRequest + const socket = yield* Effect.orDie(request.upgrade) + yield* Effect.orDie(socket.run(() => Effect.void)) + return HttpServerResponse.empty() + }) + ).pipe( + HttpRouter.serve, + Layer.build + ) + const server = yield* HttpServer.HttpServer + const port = (server.address as HttpServer.TcpAddress).port + + const connect = (perMessageDeflate: boolean) => + Effect.acquireRelease( + Effect.callback((resume) => { + const ws = new NodeWS.WebSocket(`ws://127.0.0.1:${port}/ws`, { perMessageDeflate }) + ws.on("open", () => resume(Effect.succeed(ws))) + ws.on("error", (error) => resume(Effect.fail(error))) + }), + (ws) => Effect.sync(() => ws.close()) + ) + + // layerTest configures websocket: { perMessageDeflate: true }, so the + // server accepts the extension when the client offers it... + const compressed = yield* connect(true) + expect(compressed.extensions).toContain("permessage-deflate") + + // ...and clients that do not offer it still connect uncompressed. + const plain = yield* connect(false) + expect(plain.extensions).not.toContain("permessage-deflate") + }).pipe(Effect.scoped, Effect.provide(layerTestWebsocket))) }) +const layerTestWebsocket = HttpServer.layerTestClient.pipe( + Layer.provide( + Layer.fresh(FetchHttpClient.layer).pipe( + Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ keepalive: false })) + ) + ), + Layer.provideMerge(NodeHttpServer.layer(Http.createServer, { + port: 0, + websocket: { perMessageDeflate: true } + })) +) + const tcpPort = (server: Http.Server): number => { const address = server.address() assert(address !== null && typeof address !== "string") From 51c31fbbc9c248de7c0147a271b4f6c40422989c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 28 Jul 2026 04:36:29 -0700 Subject: [PATCH 2/2] Address review: inline websocket option docs, trim changeset, drop binaryType from Bun options Co-Authored-By: Claude Fable 5 --- .changeset/http-server-websocket-options.md | 2 -- packages/platform-bun/src/BunHttpServer.ts | 2 +- packages/platform-node/src/NodeHttpServer.ts | 13 ++++++------- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.changeset/http-server-websocket-options.md b/.changeset/http-server-websocket-options.md index 3d0f3363603..177d15b7e9a 100644 --- a/.changeset/http-server-websocket-options.md +++ b/.changeset/http-server-websocket-options.md @@ -5,8 +5,6 @@ Allow configuring the WebSocket server in `NodeHttpServer` and `BunHttpServer`. -The HTTP server layers previously hardcoded their WebSocket wiring — `NodeHttpServer` always constructed `new WebSocketServer({ noServer: true })`, and `BunHttpServer` overwrote any `websocket` key in the serve options with its own handlers. There was no way to enable `permessage-deflate` compression (which browsers offer on every connection) or tune payload limits. - Both servers now accept a `websocket` option that is forwarded to the underlying implementation, with the wiring/lifecycle options the server manages excluded from the type: ```ts diff --git a/packages/platform-bun/src/BunHttpServer.ts b/packages/platform-bun/src/BunHttpServer.ts index aa3c14d5760..e503837c24e 100644 --- a/packages/platform-bun/src/BunHttpServer.ts +++ b/packages/platform-bun/src/BunHttpServer.ts @@ -82,7 +82,7 @@ export type ServeOptions = */ export type WebSocketOptions = Omit< Bun.WebSocketHandler, - "open" | "message" | "close" | "drain" | "ping" | "pong" | "data" + "open" | "message" | "close" | "drain" | "ping" | "pong" | "data" | "binaryType" > /** diff --git a/packages/platform-node/src/NodeHttpServer.ts b/packages/platform-node/src/NodeHttpServer.ts index 120f0e4a779..7638545d93b 100644 --- a/packages/platform-node/src/NodeHttpServer.ts +++ b/packages/platform-node/src/NodeHttpServer.ts @@ -65,19 +65,18 @@ import { NodeWS } from "./NodeSocket.ts" /** * Options accepted by the Node `HttpServer` constructors and layers. * - * **Details** - * - * `websocket` is forwarded to the underlying `ws` `WebSocketServer`, minus the - * wiring options the server manages itself. Use it to enable - * `permessage-deflate` compression or tune payload limits, e.g. - * `NodeHttpServer.layer(() => createServer(), { port: 3000, websocket: { perMessageDeflate: true } })`. - * * @category options * @since 4.0.0 */ export interface Options extends Net.ListenOptions { readonly disablePreemptiveShutdown?: boolean | undefined readonly gracefulShutdownTimeout?: Duration.Input | undefined + /** + * Options forwarded to the underlying `ws` `WebSocketServer`, minus the + * wiring options the server manages itself. Use this to enable + * `permessage-deflate` compression or tune payload limits, e.g. + * `websocket: { perMessageDeflate: true }`. + */ readonly websocket?: | Omit | undefined