@rhythmjs/http 0.0.3 → 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 +77 -0
- package/dist/multipart/multipart.d.ts +23 -0
- package/dist/multipart/multipart.js +109 -0
- package/dist/sse/sse.d.ts +8 -0
- package/dist/sse/sse.js +17 -0
- package/dist/stream/stream.d.ts +8 -0
- package/dist/stream/stream.js +18 -0
- package/package.json +20 -4
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
|
package/dist/sse/sse.js
ADDED
|
@@ -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
|
-
"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"
|
|
@@ -67,8 +83,8 @@
|
|
|
67
83
|
"vite-plus": "^1.0.0"
|
|
68
84
|
},
|
|
69
85
|
"peerDependencies": {
|
|
70
|
-
"@rhythmjs/rhythm": "^0.0.
|
|
71
|
-
"@rhythmjs/router": "^0.0.
|
|
86
|
+
"@rhythmjs/rhythm": "^0.0.9",
|
|
87
|
+
"@rhythmjs/router": "^0.0.9",
|
|
72
88
|
"i18next": "*"
|
|
73
89
|
},
|
|
74
90
|
"peerDependenciesMeta": {
|