Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cap-rpc-streaming-buffers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/rpc": patch
---

Cap NDJSON and MessagePack streaming decoder buffers with configurable limits.
6 changes: 6 additions & 0 deletions .changeset/configure-cluster-rpc-buffer-limits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@effect/platform-node": patch
"@effect/platform-bun": patch
---

Allow configuring cluster RPC serialization buffer limits.
5 changes: 4 additions & 1 deletion packages/platform-bun/src/BunClusterHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export const layer = <
>(options: {
readonly transport: "http" | "websocket"
readonly serialization?: "msgpack" | "ndjson" | undefined
readonly serializationMaxBufferSize?: number | "unbounded" | undefined
readonly clientOnly?: ClientOnly | undefined
readonly storage?: Storage | undefined
readonly shardingConfig?: Partial<ShardingConfig.ShardingConfig["Type"]> | undefined
Expand Down Expand Up @@ -117,7 +118,9 @@ export const layer = <
),
Layer.provide(ShardingConfig.layerFromEnv(options?.shardingConfig)),
Layer.provide(
options?.serialization === "ndjson" ? RpcSerialization.layerNdjson : RpcSerialization.layerMsgPack
options?.serialization === "ndjson"
? RpcSerialization.layerNdjsonWith({ maxBufferSize: options.serializationMaxBufferSize })
: RpcSerialization.layerMsgPackWith({ maxBufferSize: options.serializationMaxBufferSize })
)
) as any
}
5 changes: 4 additions & 1 deletion packages/platform-bun/src/BunClusterSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const layer = <
>(
options?: {
readonly serialization?: "msgpack" | "ndjson" | undefined
readonly serializationMaxBufferSize?: number | "unbounded" | undefined
readonly clientOnly?: ClientOnly | undefined
readonly storage?: Storage | undefined
readonly shardingConfig?: Partial<ShardingConfig.ShardingConfig["Type"]> | undefined
Expand Down Expand Up @@ -95,7 +96,9 @@ export const layer = <
),
Layer.provide(ShardingConfig.layerFromEnv(options?.shardingConfig)),
Layer.provide(
options?.serialization === "ndjson" ? RpcSerialization.layerNdjson : RpcSerialization.layerMsgPack
options?.serialization === "ndjson"
? RpcSerialization.layerNdjsonWith({ maxBufferSize: options?.serializationMaxBufferSize })
: RpcSerialization.layerMsgPackWith({ maxBufferSize: options?.serializationMaxBufferSize })
)
) as any
}
5 changes: 4 additions & 1 deletion packages/platform-node/src/NodeClusterHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export const layer = <
>(options: {
readonly transport: "http" | "websocket"
readonly serialization?: "msgpack" | "ndjson" | undefined
readonly serializationMaxBufferSize?: number | "unbounded" | undefined
readonly clientOnly?: ClientOnly | undefined
readonly storage?: Storage | undefined
readonly runnerHealth?: Health | undefined
Expand Down Expand Up @@ -112,7 +113,9 @@ export const layer = <
),
Layer.provide(ShardingConfig.layerFromEnv(options?.shardingConfig)),
Layer.provide(
options?.serialization === "ndjson" ? RpcSerialization.layerNdjson : RpcSerialization.layerMsgPack
options?.serialization === "ndjson"
? RpcSerialization.layerNdjsonWith({ maxBufferSize: options.serializationMaxBufferSize })
: RpcSerialization.layerMsgPackWith({ maxBufferSize: options.serializationMaxBufferSize })
)
) as any
}
Expand Down
5 changes: 4 additions & 1 deletion packages/platform-node/src/NodeClusterSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const layer = <
>(
options?: {
readonly serialization?: "msgpack" | "ndjson" | undefined
readonly serializationMaxBufferSize?: number | "unbounded" | undefined
readonly clientOnly?: ClientOnly | undefined
readonly storage?: Storage | undefined
readonly runnerHealth?: Health | undefined
Expand Down Expand Up @@ -106,7 +107,9 @@ export const layer = <
),
Layer.provide(ShardingConfig.layerFromEnv(options?.shardingConfig)),
Layer.provide(
options?.serialization === "ndjson" ? RpcSerialization.layerNdjson : RpcSerialization.layerMsgPack
options?.serialization === "ndjson"
? RpcSerialization.layerNdjsonWith({ maxBufferSize: options?.serializationMaxBufferSize })
: RpcSerialization.layerMsgPackWith({ maxBufferSize: options?.serializationMaxBufferSize })
)
) as any
}
Expand Down
168 changes: 129 additions & 39 deletions packages/rpc/src/RpcSerialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,44 @@
* @since 1.0.0
*/
import * as Context from "effect/Context"
import * as Data from "effect/Data"
import * as Layer from "effect/Layer"
import { hasProperty } from "effect/Predicate"
import * as Msgpackr from "msgpackr"
import type * as RpcMessage from "./RpcMessage.js"

const defaultMaxBufferSize = 16 * 1024 * 1024

/**
* @since 1.0.0
* @category errors
*/
export class RpcSerializationError extends Data.TaggedError("RpcSerializationError")<{
readonly reason: "BufferSizeExceeded"
readonly maxBufferSize: number
readonly bufferSize: number
}> {
/**
* @since 1.0.0
*/
get message() {
return `RPC serialization buffer exceeded the maximum size of ${this.maxBufferSize}`
}
}

/**
* @since 1.0.0
* @category serialization
*/
export interface StreamOptions {
readonly maxBufferSize?: number | "unbounded" | undefined
}

const isBufferSizeExceeded = (
bufferSize: number,
maxBufferSize: number | "unbounded"
): maxBufferSize is number => maxBufferSize !== "unbounded" && bufferSize > maxBufferSize

/**
* @since 1.0.0
* @category serialization
Expand Down Expand Up @@ -46,41 +79,68 @@ export const json: RpcSerialization["Type"] = RpcSerialization.of({
* @since 1.0.0
* @category serialization
*/
export const ndjson: RpcSerialization["Type"] = RpcSerialization.of({
contentType: "application/ndjson",
includesFraming: true,
unsafeMake: () => {
const decoder = new TextDecoder()
let buffer = ""
return ({
decode: (bytes) => {
buffer += typeof bytes === "string" ? bytes : decoder.decode(bytes)
let position = 0
let nlIndex = buffer.indexOf("\n", position)
const items: Array<unknown> = []
while (nlIndex !== -1) {
const item = JSON.parse(buffer.slice(position, nlIndex))
items.push(item)
position = nlIndex + 1
nlIndex = buffer.indexOf("\n", position)
}
buffer = buffer.slice(position)
return items
},
encode: (response) => {
if (Array.isArray(response)) {
if (response.length === 0) return undefined
let data = ""
for (let i = 0; i < response.length; i++) {
data += JSON.stringify(response[i]) + "\n"
export const makeNdjson = (options?: StreamOptions): RpcSerialization["Type"] => {
const maxBufferSize = options?.maxBufferSize ?? defaultMaxBufferSize
return RpcSerialization.of({
contentType: "application/ndjson",
includesFraming: true,
unsafeMake: () => {
const decoder = new TextDecoder()
let buffer = ""
return ({
decode: (bytes) => {
buffer += typeof bytes === "string" ? bytes : decoder.decode(bytes)
let position = 0
let nlIndex = buffer.indexOf("\n", position)
const items: Array<unknown> = []
while (nlIndex !== -1) {
const bufferSize = nlIndex - position
if (isBufferSizeExceeded(bufferSize, maxBufferSize)) {
buffer = ""
throw new RpcSerializationError({
reason: "BufferSizeExceeded",
maxBufferSize,
bufferSize
})
}
const item = JSON.parse(buffer.slice(position, nlIndex))
items.push(item)
position = nlIndex + 1
nlIndex = buffer.indexOf("\n", position)
}
buffer = buffer.slice(position)
const bufferSize = buffer.length
if (isBufferSizeExceeded(bufferSize, maxBufferSize)) {
buffer = ""
throw new RpcSerializationError({
reason: "BufferSizeExceeded",
maxBufferSize,
bufferSize
})
}
return items
},
encode: (response) => {
if (Array.isArray(response)) {
if (response.length === 0) return undefined
let data = ""
for (let i = 0; i < response.length; i++) {
data += JSON.stringify(response[i]) + "\n"
}
return data
}
return data
return JSON.stringify(response) + "\n"
}
return JSON.stringify(response) + "\n"
}
})
}
})
})
}
})
}

/**
* @since 1.0.0
* @category serialization
*/
export const ndjson: RpcSerialization["Type"] = makeNdjson()

/**
* @since 1.0.0
Expand Down Expand Up @@ -119,12 +179,13 @@ export const jsonRpc = (options?: {
*/
export const ndJsonRpc = (options?: {
readonly contentType?: string | undefined
readonly maxBufferSize?: number | "unbounded" | undefined
}): RpcSerialization["Type"] =>
RpcSerialization.of({
contentType: options?.contentType ?? "application/json-rpc",
includesFraming: true,
unsafeMake: () => {
const parser = ndjson.unsafeMake()
const parser = makeNdjson(options).unsafeMake()
const batches = new Map<string, {
readonly size: number
readonly responses: Map<string, RpcMessage.FromServerEncoded>
Expand Down Expand Up @@ -397,13 +458,16 @@ type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse
* @since 1.0.0
* @category serialization
*/
export const makeMsgPack = (options?: Msgpackr.Options | undefined): RpcSerialization["Type"] =>
RpcSerialization.of({
export const makeMsgPack = (
options?: Msgpackr.Options & StreamOptions | undefined
): RpcSerialization["Type"] => {
const { maxBufferSize = defaultMaxBufferSize, ...msgpackOptions } = options ?? {}
return RpcSerialization.of({
contentType: "application/msgpack",
includesFraming: true,
unsafeMake() {
const unpackr = new Msgpackr.Unpackr(options)
const packr = new Msgpackr.Packr(options)
const unpackr = new Msgpackr.Unpackr(msgpackOptions)
const packr = new Msgpackr.Packr(msgpackOptions)
const encoder = new TextEncoder()
let incomplete: Uint8Array | undefined = undefined
return {
Expand All @@ -422,7 +486,16 @@ export const makeMsgPack = (options?: Msgpackr.Options | undefined): RpcSerializ
} catch (error_) {
const error = error_ as any
if (error.incomplete) {
incomplete = buf.subarray(error.lastPosition)
const nextIncomplete = buf.subarray(error.lastPosition)
const bufferSize = nextIncomplete.length
if (isBufferSizeExceeded(bufferSize, maxBufferSize)) {
throw new RpcSerializationError({
reason: "BufferSizeExceeded",
maxBufferSize,
bufferSize
})
}
incomplete = nextIncomplete
return error.values ?? []
}
throw error_
Expand All @@ -432,6 +505,7 @@ export const makeMsgPack = (options?: Msgpackr.Options | undefined): RpcSerializ
}
}
})
}

/**
* @since 1.0.0
Expand Down Expand Up @@ -461,6 +535,13 @@ export const layerJson: Layer.Layer<RpcSerialization> = Layer.succeed(RpcSeriali
*/
export const layerNdjson: Layer.Layer<RpcSerialization> = Layer.succeed(RpcSerialization, ndjson)

/**
* @since 1.0.0
* @category serialization
*/
export const layerNdjsonWith = (options?: StreamOptions): Layer.Layer<RpcSerialization> =>
Layer.succeed(RpcSerialization, makeNdjson(options))

/**
* A rpc serialization layer that uses JSON-RPC for serialization.
*
Expand All @@ -480,6 +561,7 @@ export const layerJsonRpc = (options?: {
*/
export const layerNdJsonRpc = (options?: {
readonly contentType?: string | undefined
readonly maxBufferSize?: number | "unbounded" | undefined
}): Layer.Layer<RpcSerialization> => Layer.succeed(RpcSerialization, ndJsonRpc(options))

/**
Expand All @@ -492,3 +574,11 @@ export const layerNdJsonRpc = (options?: {
* @category serialization
*/
export const layerMsgPack: Layer.Layer<RpcSerialization> = Layer.succeed(RpcSerialization, msgPack)

/**
* @since 1.0.0
* @category serialization
*/
export const layerMsgPackWith = (
options?: Msgpackr.Options & StreamOptions | undefined
): Layer.Layer<RpcSerialization> => Layer.succeed(RpcSerialization, makeMsgPack(options))
6 changes: 5 additions & 1 deletion packages/rpc/src/RpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as HttpRouter from "@effect/platform/HttpRouter"
import type * as HttpServerError from "@effect/platform/HttpServerError"
import * as HttpServerRequest from "@effect/platform/HttpServerRequest"
import * as HttpServerResponse from "@effect/platform/HttpServerResponse"
import type * as Socket from "@effect/platform/Socket"
import * as Socket from "@effect/platform/Socket"
import * as SocketServer from "@effect/platform/SocketServer"
import * as Transferable from "@effect/platform/Transferable"
import type { WorkerError } from "@effect/platform/WorkerError"
Expand All @@ -29,6 +29,7 @@ import * as Layer from "effect/Layer"
import * as Mailbox from "effect/Mailbox"
import * as Option from "effect/Option"
import { type ParseError, TreeFormatter } from "effect/ParseResult"
import * as Predicate from "effect/Predicate"
import * as Runtime from "effect/Runtime"
import * as RuntimeFlags from "effect/RuntimeFlags"
import * as Schedule from "effect/Schedule"
Expand Down Expand Up @@ -1484,6 +1485,9 @@ const makeSocketProtocol = Effect.gen(function*() {
step: constVoid
})
} catch (cause) {
if (Predicate.isTagged(cause, "RpcSerializationError")) {
return writeRaw(new Socket.CloseEvent(1009, (cause as RpcSerialization.RpcSerializationError).message))
}
return writeRaw(parser.encode(ResponseDefectEncoded(cause))!)
}
}).pipe(
Expand Down
Loading
Loading