@rhythmjs/http 0.0.4 → 0.0.5

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/README.md CHANGED
@@ -119,6 +119,57 @@ new RhythmRouter().use<I18nContext>(i18n({ i18next })).get("/greet", (ctx) => {
119
119
  - The standalone `detectLanguage(request, options?)` and `parseAcceptLanguage(header)` helpers are
120
120
  exported too.
121
121
 
122
+ ## `@rhythmjs/http/sse`
123
+
124
+ Route middleware that applies the Server-Sent Events response headers. The handler owns the body:
125
+ assign any `ReadableStream` of SSE frames — driven by a writer, a generator, an observable bridge, or
126
+ anything else.
127
+
128
+ ```ts
129
+ import { sse } from "@rhythmjs/http/sse";
130
+
131
+ new RhythmRouter().get("/events", sse(), (ctx) => {
132
+ const encoder = new TextEncoder();
133
+ ctx.response.body = new ReadableStream<Uint8Array>({
134
+ start(controller) {
135
+ controller.enqueue(encoder.encode("data: hello\n\n"));
136
+ controller.close();
137
+ },
138
+ });
139
+ });
140
+ ```
141
+
142
+ - Defaults, each applied only if absent after the handler runs: `content-type: text/event-stream`,
143
+ `cache-control: no-cache, no-transform`, `connection: keep-alive`, `x-accel-buffering: no`
144
+ (disables nginx proxy buffering). Headers set by the handler or by other middleware win over them.
145
+ - `SseOptions.headers` — extra headers that override everything, including handler-set values.
146
+ - Every server adapter streams `ReadableStream` bodies with backpressure; `ctx.request.signal` aborts
147
+ on client disconnect, so producers can stop cleanly. The body streams after the middleware chain
148
+ resolves, so after-`next()` middleware sees time-to-headers, not the lifetime of the stream.
149
+
150
+ ## `@rhythmjs/http/stream`
151
+
152
+ Route middleware that applies plain streaming response headers — the non-SSE sibling of
153
+ `@rhythmjs/http/sse`. The handler owns the body: assign any `ReadableStream` (progressive text,
154
+ NDJSON, LLM tokens, proxied upstream bodies).
155
+
156
+ ```ts
157
+ import { stream } from "@rhythmjs/http/stream";
158
+
159
+ new RhythmRouter().get("/report", stream(), (ctx) => {
160
+ ctx.response.body = upstream.body;
161
+ });
162
+ ```
163
+
164
+ - Defaults, each applied only if absent after the handler runs: `content-type: text/plain`,
165
+ `cache-control: no-cache, no-transform`, `connection: keep-alive`, `x-accel-buffering: no`, and
166
+ `x-content-type-options: nosniff` (stops browsers sniffing the stream into another type). Headers
167
+ set by the handler or by other middleware win over them.
168
+ - `StreamOptions.headers` — extra headers that override everything, including handler-set values.
169
+ - Same streaming model as `sse`: adapters stream `ReadableStream` bodies with backpressure,
170
+ `ctx.request.signal` aborts on client disconnect, and the body streams after the middleware chain
171
+ resolves.
172
+
122
173
  ## `@rhythmjs/http/timeout`
123
174
 
124
175
  Fails requests that exceed a deadline with `504 { "success": false, "status": 504, "message": "Gateway Timeout" }`.
@@ -147,6 +198,32 @@ new RhythmRouter().use(bodyLimit(1024 * 1024)).post("/upload", async (ctx) => {
147
198
  });
148
199
  ```
149
200
 
201
+ ## `@rhythmjs/http/multipart`
202
+
203
+ Parses `multipart/form-data` request bodies once and exposes a `MultipartForm` on the context, with
204
+ limits enforced before the handler runs. Uses the runtime's native multipart parser.
205
+
206
+ ```ts
207
+ import { multipart, type MultipartContext } from "@rhythmjs/http/multipart";
208
+
209
+ new RhythmRouter().post("/upload", multipart({ maxBytes: 10_000_000, maxFiles: 3 }), (ctx) => {
210
+ ctx.form.get("title"); // string | undefined
211
+ ctx.form.file("avatar"); // File | undefined
212
+ ctx.form.files(); // File[]
213
+ ctx.response.body = "stored";
214
+ });
215
+ ```
216
+
217
+ - `ctx.form` — `get(name)` / `getAll(name)` (string fields), `file(name)` / `files(name?)` (`File`
218
+ entries), and `data` (the raw `FormData`).
219
+ - Rejections, all as `{ "success": false, "status": ..., "message": ... }`: `415` for non-multipart
220
+ content types, `400` for a missing or malformed body, `413` when `maxBytes` (total body, enforced
221
+ while reading via `Content-Length` or byte counting), `maxFileSize`, `maxFiles`, or `maxFields` is
222
+ exceeded.
223
+ - The body is parsed once; downstream middleware and handlers share `ctx.form` instead of re-reading
224
+ the single-use body stream. Fields and files are held in memory — set `maxBytes` in production, and
225
+ keep streaming-to-disk uploads out of scope for this module.
226
+
150
227
  ## `@rhythmjs/http/request-scope`
151
228
 
152
229
  Opens a per-request scope (backed by
@@ -0,0 +1,23 @@
1
+ import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/multipart/multipart.d.ts
4
+ export interface MultipartOptions {
5
+ maxBytes?: number;
6
+ maxFileSize?: number;
7
+ maxFiles?: number;
8
+ maxFields?: number;
9
+ }
10
+ export declare class MultipartForm {
11
+ #private;
12
+ constructor(data: FormData);
13
+ get data(): FormData;
14
+ get(name: string): string | undefined;
15
+ getAll(name: string): string[];
16
+ file(name: string): File | undefined;
17
+ files(name?: string): File[];
18
+ }
19
+ export type MultipartContext = {
20
+ form: MultipartForm;
21
+ };
22
+ export declare function multipart(options?: MultipartOptions): DeriveMiddleware<RhythmHttpContext, MultipartContext>;
23
+ //#endregion
@@ -0,0 +1,109 @@
1
+ //#region src/multipart/multipart.ts
2
+ var MultipartForm = class {
3
+ #data;
4
+ constructor(data) {
5
+ this.#data = data;
6
+ }
7
+ get data() {
8
+ return this.#data;
9
+ }
10
+ get(name) {
11
+ const value = this.#data.get(name);
12
+ return typeof value === "string" ? value : void 0;
13
+ }
14
+ getAll(name) {
15
+ return this.#data.getAll(name).filter((value) => typeof value === "string");
16
+ }
17
+ file(name) {
18
+ for (const value of this.#data.getAll(name)) if (value instanceof File) return value;
19
+ }
20
+ files(name) {
21
+ return (name === void 0 ? [...this.#data.values()] : this.#data.getAll(name)).filter((value) => value instanceof File);
22
+ }
23
+ };
24
+ var PayloadTooLargeError = class extends Error {};
25
+ function isTooLarge(error) {
26
+ let current = error;
27
+ for (let depth = 0; depth < 8 && current !== null && current !== void 0; depth++) {
28
+ if (current instanceof PayloadTooLargeError) return true;
29
+ current = current.cause;
30
+ }
31
+ return false;
32
+ }
33
+ function parse(request, maxBytes) {
34
+ if (maxBytes === void 0 || request.body === null) return request.formData();
35
+ let total = 0;
36
+ const reader = request.body.getReader();
37
+ const limited = new ReadableStream({ async pull(controller) {
38
+ const { done, value } = await reader.read();
39
+ if (done) {
40
+ controller.close();
41
+ return;
42
+ }
43
+ total += value.byteLength;
44
+ if (total > maxBytes) controller.error(new PayloadTooLargeError());
45
+ else controller.enqueue(value);
46
+ } });
47
+ const headers = new Headers(request.headers);
48
+ headers.delete("content-length");
49
+ const init = {
50
+ method: request.method,
51
+ headers,
52
+ body: limited,
53
+ duplex: "half"
54
+ };
55
+ return new Request(request.url, init).formData();
56
+ }
57
+ function multipart(options = {}) {
58
+ const { maxBytes, maxFileSize, maxFiles, maxFields } = options;
59
+ const middleware = async (ctx, next) => {
60
+ const reject = (status, message) => {
61
+ ctx.response.status = status;
62
+ ctx.response.headers.set("content-type", "application/json");
63
+ ctx.response.body = JSON.stringify({
64
+ success: false,
65
+ status,
66
+ message
67
+ });
68
+ };
69
+ if (!(ctx.request.headers.get("content-type") ?? "").toLowerCase().startsWith("multipart/form-data")) {
70
+ reject(415, "Unsupported Media Type");
71
+ return;
72
+ }
73
+ if (ctx.request.body === null) {
74
+ reject(400, "Bad Request");
75
+ return;
76
+ }
77
+ const contentLength = ctx.request.headers.get("content-length");
78
+ if (maxBytes !== void 0 && contentLength !== null && Number(contentLength) > maxBytes) {
79
+ reject(413, "Payload Too Large");
80
+ return;
81
+ }
82
+ let data;
83
+ try {
84
+ data = await parse(ctx.request, maxBytes);
85
+ } catch (error) {
86
+ if (isTooLarge(error)) reject(413, "Payload Too Large");
87
+ else reject(400, "Bad Request");
88
+ return;
89
+ }
90
+ let fileCount = 0;
91
+ let fieldCount = 0;
92
+ for (const value of data.values()) if (value instanceof File) {
93
+ fileCount++;
94
+ if (maxFileSize !== void 0 && value.size > maxFileSize) {
95
+ reject(413, "Payload Too Large");
96
+ return;
97
+ }
98
+ } else fieldCount++;
99
+ if (maxFiles !== void 0 && fileCount > maxFiles || maxFields !== void 0 && fieldCount > maxFields) {
100
+ reject(413, "Payload Too Large");
101
+ return;
102
+ }
103
+ ctx.form = new MultipartForm(data);
104
+ await next();
105
+ };
106
+ return middleware;
107
+ }
108
+ //#endregion
109
+ export { MultipartForm, multipart };
@@ -0,0 +1,8 @@
1
+ import { Middleware } from "@rhythmjs/rhythm/types";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/sse/sse.d.ts
4
+ export interface SseOptions {
5
+ headers?: ConstructorParameters<typeof Headers>[0];
6
+ }
7
+ export declare function sse(options?: SseOptions): Middleware<RhythmHttpContext>;
8
+ //#endregion
@@ -0,0 +1,17 @@
1
+ //#region src/sse/sse.ts
2
+ const defaults = [
3
+ ["content-type", "text/event-stream; charset=utf-8"],
4
+ ["cache-control", "no-cache, no-transform"],
5
+ ["connection", "keep-alive"],
6
+ ["x-accel-buffering", "no"]
7
+ ];
8
+ function sse(options = {}) {
9
+ const overrides = new Headers(options.headers);
10
+ return async (ctx, next) => {
11
+ await next();
12
+ for (const [name, value] of defaults) if (!ctx.response.headers.has(name)) ctx.response.headers.set(name, value);
13
+ for (const [name, value] of overrides) ctx.response.headers.set(name, value);
14
+ };
15
+ }
16
+ //#endregion
17
+ export { sse };
@@ -0,0 +1,8 @@
1
+ import { Middleware } from "@rhythmjs/rhythm/types";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/stream/stream.d.ts
4
+ export interface StreamOptions {
5
+ headers?: ConstructorParameters<typeof Headers>[0];
6
+ }
7
+ export declare function stream(options?: StreamOptions): Middleware<RhythmHttpContext>;
8
+ //#endregion
@@ -0,0 +1,18 @@
1
+ //#region src/stream/stream.ts
2
+ const defaults = [
3
+ ["content-type", "text/plain; charset=utf-8"],
4
+ ["cache-control", "no-cache, no-transform"],
5
+ ["connection", "keep-alive"],
6
+ ["x-accel-buffering", "no"],
7
+ ["x-content-type-options", "nosniff"]
8
+ ];
9
+ function stream(options = {}) {
10
+ const overrides = new Headers(options.headers);
11
+ return async (ctx, next) => {
12
+ await next();
13
+ for (const [name, value] of defaults) if (!ctx.response.headers.has(name)) ctx.response.headers.set(name, value);
14
+ for (const [name, value] of overrides) ctx.response.headers.set(name, value);
15
+ };
16
+ }
17
+ //#endregion
18
+ export { stream };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rhythmjs/http",
3
- "version": "0.0.4",
4
- "description": "HTTP utility middleware (cookies, sessions, etag, timeout, body limit) for Rhythm routers and handlers.",
3
+ "version": "0.0.5",
4
+ "description": "HTTP utility middleware (cookies, sessions, etag, sse, streaming, multipart, timeout, body limit) for Rhythm routers and handlers.",
5
5
  "keywords": [
6
6
  "async-local-storage",
7
7
  "body-limit",
@@ -11,9 +11,13 @@
11
11
  "i18n",
12
12
  "i18next",
13
13
  "middleware",
14
+ "multipart",
14
15
  "request-scope",
15
16
  "rhythm",
17
+ "server-sent-events",
16
18
  "session",
19
+ "sse",
20
+ "streaming",
17
21
  "timeout"
18
22
  ],
19
23
  "license": "ISC",
@@ -43,6 +47,10 @@
43
47
  "types": "./dist/i18n/i18n.d.ts",
44
48
  "default": "./dist/i18n/i18n.js"
45
49
  },
50
+ "./multipart": {
51
+ "types": "./dist/multipart/multipart.d.ts",
52
+ "default": "./dist/multipart/multipart.js"
53
+ },
46
54
  "./request-scope": {
47
55
  "types": "./dist/request-scope/request-scope.d.ts",
48
56
  "default": "./dist/request-scope/request-scope.js"
@@ -51,6 +59,14 @@
51
59
  "types": "./dist/session/session.d.ts",
52
60
  "default": "./dist/session/session.js"
53
61
  },
62
+ "./sse": {
63
+ "types": "./dist/sse/sse.d.ts",
64
+ "default": "./dist/sse/sse.js"
65
+ },
66
+ "./stream": {
67
+ "types": "./dist/stream/stream.d.ts",
68
+ "default": "./dist/stream/stream.js"
69
+ },
54
70
  "./timeout": {
55
71
  "types": "./dist/timeout/timeout.d.ts",
56
72
  "default": "./dist/timeout/timeout.js"