@shivaedev/platform 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-10-16
4
+
5
+ ### Added
6
+
7
+ - Export `readBoundedBody` and `RequestBodyTooLarge` from `@shivaedev/platform/boundedBody.ts`. Native Node HTTP routes can apply Effect's `HttpIncomingMessage.MaxBodySize` and return 413 for oversized content lengths or chunked bodies while keeping the connection usable and preserving accepted bytes.
8
+
3
9
  ## 0.10.0 - 2026-10-16
4
10
 
5
11
  ### Added
package/README.md CHANGED
@@ -67,6 +67,21 @@ The listener uses `HOST` or `127.0.0.1`, uses `PORT` or 3000, and reads `DATABAS
67
67
 
68
68
  RPC request bodies are limited to 1 MiB by default. Set `MAX_BODY_SIZE` to a byte count or override it with `config.maxBodySize`, a nonnegative safe integer. An oversized NDJSON body returns a 4xx before any RPC handler runs; real HTTP tests cover fixed-length and chunked requests, byte counting, and configuration precedence. This limit applies to the host's RPC route.
69
69
 
70
+ For a native Node HTTP route, use `readBoundedBody` from `@shivaedev/platform/boundedBody.ts` with Effect's `HttpIncomingMessage.MaxBodySize`. It returns an `ArrayBuffer` without decoding the bytes. Oversized `content-length` headers are rejected before reading; chunked overflow drains the remaining body and preserves the connection for another request. `RequestBodyTooLarge` is a typed failure that implements Effect's `HttpServerRespondable` protocol with status 413; other read errors remain native `HttpServerError` failures. Consume the body once through this reader and decode the result in the application.
71
+
72
+ ```ts
73
+ import { ByteSize, Effect } from "effect";
74
+ import { HttpIncomingMessage, HttpRouter, HttpServerResponse } from "effect/http";
75
+ import { readBoundedBody } from "@shivaedev/platform/boundedBody.ts";
76
+
77
+ const routes = HttpRouter.add("POST", "/webhook", (request) =>
78
+ readBoundedBody(request).pipe(
79
+ Effect.map((body) => HttpServerResponse.uint8Array(new Uint8Array(body))),
80
+ Effect.provideService(HttpIncomingMessage.MaxBodySize, ByteSize.bytes(64 * 1024)),
81
+ ),
82
+ );
83
+ ```
84
+
70
85
  RPC uses the existing `localRpcGuard`: it requires a loopback peer and Host, same-origin or absent Origin, and the NDJSON content type. Session lookup uses transport headers. With a session policy, contracts declare `Authenticated`, `MaybeAuthenticated` and `RequestTracing` as needed; the host supplies their implementations. For `AuthenticatedUser`, merge `authenticatedUserLayer(sessions)` beside the handlers as shown below. Declared errors retain their Schema types. Applications still own resource authorization and use `rpcBoundary` when they need to map unexpected failures.
71
86
 
72
87
  For local tools, use `policy: "loopback"` with contracts that do not use session middleware to serve RPC without a session provider. The guard requires a loopback peer and Host, absent or same-origin Origin, and NDJSON content type. This policy does not use `TRUSTED_PROXY` or `config.trustedProxy`. Set `rpcPath` to choose another native RPC route; `/rpc` remains the default.
@@ -0,0 +1,14 @@
1
+ import { Effect } from "effect";
2
+ import { HttpServerError, type HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
3
+ declare const RequestBodyTooLarge_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
4
+ readonly _tag: "RequestBodyTooLarge";
5
+ } & Readonly<A>;
6
+ export declare class RequestBodyTooLarge extends RequestBodyTooLarge_base<{
7
+ readonly request: HttpServerRequest.HttpServerRequest;
8
+ readonly limit: bigint;
9
+ }> implements HttpServerRespondable.Respondable {
10
+ [HttpServerRespondable.symbol](): Effect.Effect<HttpServerResponse.HttpServerResponse, never, never>;
11
+ }
12
+ export declare const readBoundedBody: (request: HttpServerRequest.HttpServerRequest) => Effect.Effect<ArrayBuffer, HttpServerError.HttpServerError | RequestBodyTooLarge, never>;
13
+ export {};
14
+ //# sourceMappingURL=boundedBody.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boundedBody.d.ts","sourceRoot":"","sources":["../../src/server/boundedBody.ts"],"names":[],"mappings":"AACA,OAAO,EAAQ,MAAM,EAAU,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAuB,eAAe,EAAE,KAAK,iBAAiB,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;;;;AAEtI,qBAAa,mBACZ,SAAQ,yBAAwC;IAC/C,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,CAAC;IACtD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB,CACD,YAAW,qBAAqB,CAAC,WAAW;IAE5C,CAAC,qBAAqB,CAAC,MAAM,CAAC,uEAE7B;CACD;AAED,eAAO,MAAM,eAAe,4IA4B1B,CAAC"}
@@ -0,0 +1,36 @@
1
+ import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
2
+ import { Data, Effect, Stream } from "effect";
3
+ import { HttpIncomingMessage, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/http";
4
+ export class RequestBodyTooLarge extends Data.TaggedError("RequestBodyTooLarge") {
5
+ [HttpServerRespondable.symbol]() {
6
+ return Effect.succeed(HttpServerResponse.empty({ status: 413 }));
7
+ }
8
+ }
9
+ export const readBoundedBody = Effect.fnUntraced(function* (request) {
10
+ const incoming = NodeHttpServerRequest.toIncomingMessage(request);
11
+ const limit = yield* HttpIncomingMessage.MaxBodySize;
12
+ const read = Effect.gen(function* () {
13
+ const length = request.headers["content-length"];
14
+ if (limit !== undefined && length !== undefined && BigInt(length) > limit) {
15
+ return yield* new RequestBodyTooLarge({ limit, request });
16
+ }
17
+ const chunks = [];
18
+ let size = 0n;
19
+ yield* NodeStream.fromReadable({
20
+ closeOnDone: false,
21
+ evaluate: () => incoming,
22
+ onError: (cause) => new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) }),
23
+ }).pipe(Stream.runForEach((chunk) => {
24
+ size += BigInt(chunk.byteLength);
25
+ if (limit !== undefined && size > limit) {
26
+ return Effect.fail(new RequestBodyTooLarge({ limit, request }));
27
+ }
28
+ chunks.push(chunk);
29
+ return Effect.void;
30
+ }));
31
+ return Uint8Array.from(Buffer.concat(chunks)).buffer;
32
+ });
33
+ // Drain unread bytes without destroying the socket so the server can send a response.
34
+ return yield* read.pipe(Effect.onError(() => Effect.sync(() => incoming.resume())));
35
+ });
36
+ //# sourceMappingURL=boundedBody.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"boundedBody.js","sourceRoot":"","sources":["../../src/server/boundedBody.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAA0B,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAEtI,MAAM,OAAO,mBACZ,SAAQ,IAAI,CAAC,WAAW,CAAC,qBAAqB,CAG5C;IAGF,CAAC,qBAAqB,CAAC,MAAM,CAAC;QAC7B,OAAO,MAAM,CAAC,OAAO,CAAC,kBAAkB,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC;IAClE,CAAC;CACD;AAED,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,OAA4C;IACvG,MAAM,QAAQ,GAAG,qBAAqB,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAClE,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,mBAAmB,CAAC,WAAW,CAAC;IACrD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAChC,MAAM,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;QACjD,IAAI,KAAK,KAAK,SAAS,IAAI,MAAM,KAAK,SAAS,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,KAAK,EAAE,CAAC;YAC3E,OAAO,KAAK,CAAC,CAAC,IAAI,mBAAmB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC;QAC3D,CAAC;QACD,MAAM,MAAM,GAAiB,EAAE,CAAC;QAChC,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,KAAK,CAAC,CAAC,UAAU,CAAC,YAAY,CAA8C;YAC3E,WAAW,EAAE,KAAK;YAClB,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ;YACxB,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,eAAe,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,eAAe,CAAC,iBAAiB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;SAC9H,CAAC,CAAC,IAAI,CACN,MAAM,CAAC,UAAU,CAAC,CAAC,KAAK,EAAE,EAAE;YAC3B,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YACjC,IAAI,KAAK,KAAK,SAAS,IAAI,IAAI,GAAG,KAAK,EAAE,CAAC;gBACzC,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,mBAAmB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC;YACjE,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACnB,OAAO,MAAM,CAAC,IAAI,CAAC;QACpB,CAAC,CAAC,CACF,CAAC;QACF,OAAO,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;IACtD,CAAC,CAAC,CAAC;IACH,sFAAsF;IACtF,OAAO,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC;AACrF,CAAC,CAAC,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"boundedRpcRequest.d.ts","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwC,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAE3F,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,GAAG,iBAAiB,CAAC,iBAAiB,CA0CnH"}
1
+ {"version":3,"file":"boundedRpcRequest.d.ts","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGtE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,iBAAiB,CAAC,iBAAiB,GAAG,iBAAiB,CAAC,iBAAiB,CAuBnH"}
@@ -1,30 +1,11 @@
1
- import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
2
- import { Effect, Stream } from "effect";
3
- import { HttpIncomingMessage, HttpServerError } from "effect/http";
1
+ import { Effect } from "effect";
2
+ import { HttpServerError } from "effect/http";
3
+ import { readBoundedBody } from "./boundedBody.js";
4
4
  export function boundedRpcRequest(request) {
5
- const incoming = NodeHttpServerRequest.toIncomingMessage(request);
6
- function error(cause) {
7
- return new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) });
8
- }
9
- const text = Effect.gen(function* () {
10
- const limit = yield* HttpIncomingMessage.MaxBodySize;
11
- const chunks = [];
12
- let size = 0n;
13
- yield* NodeStream.fromReadable({
14
- closeOnDone: false,
15
- evaluate: () => incoming,
16
- onError: error,
17
- }).pipe(Stream.runForEach((chunk) => {
18
- size += BigInt(chunk.byteLength);
19
- if (limit !== undefined && size > limit) {
20
- return Effect.fail(error(new Error("RPC body exceeds MAX_BODY_SIZE")));
21
- }
22
- chunks.push(chunk);
23
- return Effect.void;
24
- }));
25
- return Buffer.concat(chunks).toString("utf8");
26
- }).pipe(Effect.onError(() => Effect.sync(() => incoming.resume())), Effect.cached, Effect.runSync);
27
- const arrayBuffer = Effect.map(text, (body) => new TextEncoder().encode(body).buffer);
5
+ const arrayBuffer = readBoundedBody(request).pipe(Effect.mapError((cause) => cause._tag === "RequestBodyTooLarge"
6
+ ? new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) })
7
+ : cause), Effect.cached, Effect.runSync);
8
+ const text = Effect.map(arrayBuffer, (body) => new TextDecoder().decode(body));
28
9
  // The native text reader destroys the HTTP socket on overflow, preventing a 4xx response.
29
10
  return new Proxy(request, {
30
11
  get: (target, key, receiver) => {
@@ -1 +1 @@
1
- {"version":3,"file":"boundedRpcRequest.js","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAC1E,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,mBAAmB,EAAE,eAAe,EAA0B,MAAM,aAAa,CAAC;AAE3F,MAAM,UAAU,iBAAiB,CAAC,OAA4C;IAC7E,MAAM,QAAQ,GAAG,qBAAqB,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;IAClE,SAAS,KAAK,CAAC,KAAc;QAC5B,OAAO,IAAI,eAAe,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,eAAe,CAAC,iBAAiB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;IACnH,CAAC;IACD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QAChC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,mBAAmB,CAAC,WAAW,CAAC;QACrD,MAAM,MAAM,GAAiB,EAAE,CAAC;QAChC,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,KAAK,CAAC,CAAC,UAAU,CAAC,YAAY,CAA8C;YAC3E,WAAW,EAAE,KAAK;YAClB,QAAQ,EAAE,GAAG,EAAE,CAAC,QAAQ;YACxB,OAAO,EAAE,KAAK;SACd,CAAC,CAAC,IAAI,CACN,MAAM,CAAC,UAAU,CAAC,CAAC,KAAK,EAAE,EAAE;YAC3B,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC;YACjC,IAAI,KAAK,KAAK,SAAS,IAAI,IAAI,GAAG,KAAK,EAAE,CAAC;gBACzC,OAAO,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC,CAAC,CAAC;YACxE,CAAC;YACD,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACnB,OAAO,MAAM,CAAC,IAAI,CAAC;QACpB,CAAC,CAAC,CACF,CAAC;QACF,OAAO,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC,IAAI,CACN,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,EAC1D,MAAM,CAAC,MAAM,EACb,MAAM,CAAC,OAAO,CACd,CAAC;IACF,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC;IACtF,0FAA0F;IAC1F,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE;QACzB,GAAG,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,EAAE;YAC9B,IAAI,GAAG,KAAK,MAAM,EAAE,CAAC;gBACpB,OAAO,IAAI,CAAC;YACb,CAAC;YACD,IAAI,GAAG,KAAK,aAAa,EAAE,CAAC;gBAC3B,OAAO,WAAW,CAAC;YACpB,CAAC;YACD,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC3C,CAAC;KACD,CAAC,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"boundedRpcRequest.js","sourceRoot":"","sources":["../../src/server/boundedRpcRequest.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,eAAe,EAA0B,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,UAAU,iBAAiB,CAAC,OAA4C;IAC7E,MAAM,WAAW,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC,IAAI,CAChD,MAAM,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE,CACzB,KAAK,CAAC,IAAI,KAAK,qBAAqB;QACnC,CAAC,CAAC,IAAI,eAAe,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,IAAI,eAAe,CAAC,iBAAiB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;QAC5G,CAAC,CAAC,KAAK,CACR,EACD,MAAM,CAAC,MAAM,EACb,MAAM,CAAC,OAAO,CACd,CAAC;IACF,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;IAC/E,0FAA0F;IAC1F,OAAO,IAAI,KAAK,CAAC,OAAO,EAAE;QACzB,GAAG,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,EAAE,EAAE;YAC9B,IAAI,GAAG,KAAK,MAAM,EAAE,CAAC;gBACpB,OAAO,IAAI,CAAC;YACb,CAAC;YACD,IAAI,GAAG,KAAK,aAAa,EAAE,CAAC;gBAC3B,OAAO,WAAW,CAAC;YACpB,CAAC;YACD,OAAO,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAC;QAC3C,CAAC;KACD,CAAC,CAAC;AACJ,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/platform",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Opinionated Effect application infrastructure for ShivaeDev services",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/platform#readme",
6
6
  "bugs": {
@@ -168,6 +168,12 @@
168
168
  }
169
169
  },
170
170
  "exports": {
171
+ "./boundedBody.ts": {
172
+ "types": "./dist/server/boundedBody.d.ts",
173
+ "source": "./src/server/boundedBody.ts",
174
+ "import": "./dist/server/boundedBody.js",
175
+ "default": "./dist/server/boundedBody.js"
176
+ },
171
177
  "./appHost.ts": {
172
178
  "types": "./dist/server/appHost.d.ts",
173
179
  "source": "./src/server/appHost.ts",
@@ -341,7 +347,7 @@
341
347
  "@prisma-next/target-postgres": "0.16.0",
342
348
  "@shivaedev/effect-prisma": "0.9.0",
343
349
  "@shivaedev/effect-trpc": "0.7.0",
344
- "@shivaedev/quality": "0.21.0",
350
+ "@shivaedev/quality": "0.21.1",
345
351
  "@trpc/client": "11.19.0",
346
352
  "@trpc/server": "11.19.0",
347
353
  "@types/node": "24.19.1",
@@ -0,0 +1,45 @@
1
+ import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
2
+ import { Data, Effect, Stream } from "effect";
3
+ import { HttpIncomingMessage, HttpServerError, type HttpServerRequest, HttpServerRespondable, HttpServerResponse } from "effect/http";
4
+
5
+ export class RequestBodyTooLarge
6
+ extends Data.TaggedError("RequestBodyTooLarge")<{
7
+ readonly request: HttpServerRequest.HttpServerRequest;
8
+ readonly limit: bigint;
9
+ }>
10
+ implements HttpServerRespondable.Respondable
11
+ {
12
+ [HttpServerRespondable.symbol]() {
13
+ return Effect.succeed(HttpServerResponse.empty({ status: 413 }));
14
+ }
15
+ }
16
+
17
+ export const readBoundedBody = Effect.fnUntraced(function* (request: HttpServerRequest.HttpServerRequest) {
18
+ const incoming = NodeHttpServerRequest.toIncomingMessage(request);
19
+ const limit = yield* HttpIncomingMessage.MaxBodySize;
20
+ const read = Effect.gen(function* () {
21
+ const length = request.headers["content-length"];
22
+ if (limit !== undefined && length !== undefined && BigInt(length) > limit) {
23
+ return yield* new RequestBodyTooLarge({ limit, request });
24
+ }
25
+ const chunks: Uint8Array[] = [];
26
+ let size = 0n;
27
+ yield* NodeStream.fromReadable<Uint8Array, HttpServerError.HttpServerError>({
28
+ closeOnDone: false,
29
+ evaluate: () => incoming,
30
+ onError: (cause) => new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) }),
31
+ }).pipe(
32
+ Stream.runForEach((chunk) => {
33
+ size += BigInt(chunk.byteLength);
34
+ if (limit !== undefined && size > limit) {
35
+ return Effect.fail(new RequestBodyTooLarge({ limit, request }));
36
+ }
37
+ chunks.push(chunk);
38
+ return Effect.void;
39
+ }),
40
+ );
41
+ return Uint8Array.from(Buffer.concat(chunks)).buffer;
42
+ });
43
+ // Drain unread bytes without destroying the socket so the server can send a response.
44
+ return yield* read.pipe(Effect.onError(() => Effect.sync(() => incoming.resume())));
45
+ });
@@ -1,37 +1,18 @@
1
- import { NodeHttpServerRequest, NodeStream } from "@effect/platform-node";
2
- import { Effect, Stream } from "effect";
3
- import { HttpIncomingMessage, HttpServerError, type HttpServerRequest } from "effect/http";
1
+ import { Effect } from "effect";
2
+ import { HttpServerError, type HttpServerRequest } from "effect/http";
3
+ import { readBoundedBody } from "./boundedBody.ts";
4
4
 
5
5
  export function boundedRpcRequest(request: HttpServerRequest.HttpServerRequest): HttpServerRequest.HttpServerRequest {
6
- const incoming = NodeHttpServerRequest.toIncomingMessage(request);
7
- function error(cause: unknown) {
8
- return new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) });
9
- }
10
- const text = Effect.gen(function* () {
11
- const limit = yield* HttpIncomingMessage.MaxBodySize;
12
- const chunks: Uint8Array[] = [];
13
- let size = 0n;
14
- yield* NodeStream.fromReadable<Uint8Array, HttpServerError.HttpServerError>({
15
- closeOnDone: false,
16
- evaluate: () => incoming,
17
- onError: error,
18
- }).pipe(
19
- Stream.runForEach((chunk) => {
20
- size += BigInt(chunk.byteLength);
21
- if (limit !== undefined && size > limit) {
22
- return Effect.fail(error(new Error("RPC body exceeds MAX_BODY_SIZE")));
23
- }
24
- chunks.push(chunk);
25
- return Effect.void;
26
- }),
27
- );
28
- return Buffer.concat(chunks).toString("utf8");
29
- }).pipe(
30
- Effect.onError(() => Effect.sync(() => incoming.resume())),
6
+ const arrayBuffer = readBoundedBody(request).pipe(
7
+ Effect.mapError((cause) =>
8
+ cause._tag === "RequestBodyTooLarge"
9
+ ? new HttpServerError.HttpServerError({ reason: new HttpServerError.RequestParseError({ cause, request }) })
10
+ : cause,
11
+ ),
31
12
  Effect.cached,
32
13
  Effect.runSync,
33
14
  );
34
- const arrayBuffer = Effect.map(text, (body) => new TextEncoder().encode(body).buffer);
15
+ const text = Effect.map(arrayBuffer, (body) => new TextDecoder().decode(body));
35
16
  // The native text reader destroys the HTTP socket on overflow, preventing a 4xx response.
36
17
  return new Proxy(request, {
37
18
  get: (target, key, receiver) => {