@rhythmjs/http 0.0.1

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 rhythmjs
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @rhythmjs/http
2
+
3
+ HTTP utility middleware for [Rhythm](https://github.com/rhythmjs/rhythm) routers and handlers. Each module
4
+ is exported by its own subpath — there is no root barrel export.
5
+
6
+ ## Install
7
+
8
+ ```sh
9
+ pnpm add @rhythmjs/http @rhythmjs/rhythm @rhythmjs/router
10
+ ```
11
+
12
+ ## `@rhythmjs/http/cookies`
13
+
14
+ Parses the request `Cookie` header and exposes a `Cookies` jar on the context for reading and writing.
15
+
16
+ ```ts
17
+ import { RhythmRouter } from "@rhythmjs/router";
18
+ import { cookies, type CookiesContext } from "@rhythmjs/http/cookies";
19
+
20
+ new RhythmRouter().use<CookiesContext>(cookies()).get("/", (ctx) => {
21
+ ctx.cookies.get("theme"); // string | undefined
22
+ ctx.cookies.set("theme", "dark", { httpOnly: true, sameSite: "lax", maxAge: 3600 });
23
+ ctx.cookies.delete("legacy");
24
+ ctx.response.body = "ok";
25
+ });
26
+ ```
27
+
28
+ - `ctx.cookies` — `get(name)`, `getAll()`, `set(name, value, options?)` (defaults to `Path=/`), and
29
+ `delete(name)` (sets `Max-Age=0`). Multiple `set()` calls emit separate `Set-Cookie` headers.
30
+ - `CookieOptions` — `domain`, `expires`, `httpOnly`, `maxAge`, `path`, `sameSite`, `secure`.
31
+ - The standalone `parseCookies(header)` and `serializeCookie(name, value, options?)` helpers are exported
32
+ too.
33
+
34
+ ## `@rhythmjs/http/session`
35
+
36
+ Cookie-based sessions with a pluggable store.
37
+
38
+ ```ts
39
+ import { session, type SessionContext } from "@rhythmjs/http/session";
40
+
41
+ new RhythmRouter()
42
+ .use<SessionContext>(session())
43
+ .post("/login", (ctx) => {
44
+ ctx.session.set("user", "ada");
45
+ ctx.response.body = "logged in";
46
+ })
47
+ .get("/me", (ctx) => {
48
+ ctx.response.body = String(ctx.session.get("user") ?? "anonymous");
49
+ })
50
+ .post("/logout", (ctx) => {
51
+ ctx.session.destroy();
52
+ ctx.response.body = "logged out";
53
+ });
54
+ ```
55
+
56
+ - `ctx.session` — `id`, `get(key)`, `set(key, value)`, `delete(key)`, `destroy()`.
57
+ - The session cookie (`sid` by default; `HttpOnly`, `SameSite=Lax`, `Path=/`) is only written when the
58
+ session is first used, and `destroy()` deletes the stored session and expires the cookie. Unknown or
59
+ expired session ids get a fresh session — the cookie value is never trusted as-is.
60
+ - `SessionOptions` — `store` (any `SessionStore` implementation; the bundled `MemoryStore` by default,
61
+ suitable for a single process), `cookieName`, `maxAge` (seconds, default 86400), `path`, `secure`,
62
+ `sameSite`.
63
+
64
+ ## `@rhythmjs/http/etag`
65
+
66
+ Adds an `ETag` header derived from the response body (SHA-1) and answers `304 Not Modified` when the
67
+ request's `If-None-Match` matches.
68
+
69
+ ```ts
70
+ import { etag } from "@rhythmjs/http/etag";
71
+
72
+ new RhythmRouter().use(etag()).get("/report", (ctx) => {
73
+ ctx.response.body = expensiveReport();
74
+ });
75
+ ```
76
+
77
+ - `etag({ weak: true })` emits `W/"..."` tags.
78
+ - Only successful (2xx) string bodies are tagged; a pre-set `ETag` is left alone.
79
+
80
+ ## `@rhythmjs/http/timeout`
81
+
82
+ Fails requests that exceed a deadline with `504 { "success": false, "status": 504, "message": "Gateway Timeout" }`.
83
+
84
+ ```ts
85
+ import { timeout } from "@rhythmjs/http/timeout";
86
+
87
+ new RhythmRouter().use(timeout(5000)).get("/slow", async (ctx) => {
88
+ ctx.response.body = await slowUpstream();
89
+ });
90
+ ```
91
+
92
+ Downstream errors still propagate as errors; only the deadline produces a 504. Note that the downstream
93
+ work is not aborted — its result is discarded.
94
+
95
+ ## `@rhythmjs/http/body-limit`
96
+
97
+ Rejects request bodies larger than a byte limit with `413 Payload Too Large`, using `Content-Length` when
98
+ present and measuring the body otherwise.
99
+
100
+ ```ts
101
+ import { bodyLimit } from "@rhythmjs/http/body-limit";
102
+
103
+ new RhythmRouter().use(bodyLimit(1024 * 1024)).post("/upload", async (ctx) => {
104
+ ctx.response.body = "stored";
105
+ });
106
+ ```
107
+
108
+ ## Development
109
+
110
+ ```sh
111
+ pnpm install
112
+ pnpm test # vp test
113
+ pnpm typecheck # tsc --noEmit
114
+ pnpm build # vp pack
115
+ ```
@@ -0,0 +1,5 @@
1
+ import { Middleware } from "@rhythmjs/rhythm";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/body-limit/body-limit.d.ts
4
+ export declare function bodyLimit(maxBytes: number): Middleware<RhythmHttpContext>;
5
+ //#endregion
@@ -0,0 +1,29 @@
1
+ //#region src/body-limit/body-limit.ts
2
+ function bodyLimit(maxBytes) {
3
+ return async (ctx, next) => {
4
+ const reject = () => {
5
+ ctx.response.status = 413;
6
+ ctx.response.headers.set("content-type", "application/json");
7
+ ctx.response.body = JSON.stringify({
8
+ success: false,
9
+ status: 413,
10
+ message: "Payload Too Large"
11
+ });
12
+ };
13
+ const contentLength = ctx.request.headers.get("content-length");
14
+ if (contentLength !== null) {
15
+ if (Number(contentLength) > maxBytes) {
16
+ reject();
17
+ return;
18
+ }
19
+ } else if (ctx.request.body !== null) {
20
+ if ((await ctx.request.clone().arrayBuffer()).byteLength > maxBytes) {
21
+ reject();
22
+ return;
23
+ }
24
+ }
25
+ await next();
26
+ };
27
+ }
28
+ //#endregion
29
+ export { bodyLimit };
@@ -0,0 +1,27 @@
1
+ import { Middleware } from "@rhythmjs/rhythm";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/cookies/cookies.d.ts
4
+ export interface CookieOptions {
5
+ domain?: string;
6
+ expires?: Date;
7
+ httpOnly?: boolean;
8
+ maxAge?: number;
9
+ path?: string;
10
+ sameSite?: "strict" | "lax" | "none";
11
+ secure?: boolean;
12
+ }
13
+ export declare function parseCookies(header: string | null): Record<string, string>;
14
+ export declare function serializeCookie(name: string, value: string, options?: CookieOptions): string;
15
+ export declare class Cookies {
16
+ #private;
17
+ constructor(incoming: Record<string, string>, headers: Headers);
18
+ get(name: string): string | undefined;
19
+ getAll(): Record<string, string>;
20
+ set(name: string, value: string, options?: CookieOptions): void;
21
+ delete(name: string, options?: CookieOptions): void;
22
+ }
23
+ export type CookiesContext = {
24
+ cookies: Cookies;
25
+ };
26
+ export declare function cookies(): Middleware<RhythmHttpContext>;
27
+ //#endregion
@@ -0,0 +1,64 @@
1
+ //#region src/cookies/cookies.ts
2
+ function parseCookies(header) {
3
+ const out = {};
4
+ if (!header) return out;
5
+ for (const part of header.split(/;\s*/)) {
6
+ const eq = part.indexOf("=");
7
+ if (eq === -1) continue;
8
+ const name = part.slice(0, eq).trim();
9
+ if (!name) continue;
10
+ let value = part.slice(eq + 1).trim();
11
+ if (value.startsWith("\"") && value.endsWith("\"")) value = value.slice(1, -1);
12
+ try {
13
+ out[name] = decodeURIComponent(value);
14
+ } catch {
15
+ out[name] = value;
16
+ }
17
+ }
18
+ return out;
19
+ }
20
+ function serializeCookie(name, value, options = {}) {
21
+ let cookie = `${name}=${encodeURIComponent(value)}`;
22
+ if (options.maxAge !== void 0) cookie += `; Max-Age=${Math.trunc(options.maxAge)}`;
23
+ if (options.domain !== void 0) cookie += `; Domain=${options.domain}`;
24
+ if (options.path !== void 0) cookie += `; Path=${options.path}`;
25
+ if (options.expires !== void 0) cookie += `; Expires=${options.expires.toUTCString()}`;
26
+ if (options.httpOnly) cookie += "; HttpOnly";
27
+ if (options.secure) cookie += "; Secure";
28
+ if (options.sameSite !== void 0) cookie += `; SameSite=${options.sameSite.charAt(0).toUpperCase()}${options.sameSite.slice(1)}`;
29
+ return cookie;
30
+ }
31
+ var Cookies = class {
32
+ #incoming;
33
+ #headers;
34
+ constructor(incoming, headers) {
35
+ this.#incoming = incoming;
36
+ this.#headers = headers;
37
+ }
38
+ get(name) {
39
+ return this.#incoming[name];
40
+ }
41
+ getAll() {
42
+ return { ...this.#incoming };
43
+ }
44
+ set(name, value, options = {}) {
45
+ this.#headers.append("set-cookie", serializeCookie(name, value, {
46
+ path: "/",
47
+ ...options
48
+ }));
49
+ }
50
+ delete(name, options = {}) {
51
+ this.set(name, "", {
52
+ ...options,
53
+ expires: void 0,
54
+ maxAge: 0
55
+ });
56
+ }
57
+ };
58
+ function cookies() {
59
+ return async (ctx, next) => {
60
+ await next({ cookies: new Cookies(parseCookies(ctx.request.headers.get("cookie")), ctx.response.headers) });
61
+ };
62
+ }
63
+ //#endregion
64
+ export { Cookies, cookies, parseCookies, serializeCookie };
@@ -0,0 +1,8 @@
1
+ import { Middleware } from "@rhythmjs/rhythm";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/etag/etag.d.ts
4
+ export interface EtagOptions {
5
+ weak?: boolean;
6
+ }
7
+ export declare function etag(options?: EtagOptions): Middleware<RhythmHttpContext>;
8
+ //#endregion
@@ -0,0 +1,25 @@
1
+ //#region src/etag/etag.ts
2
+ async function hash(body) {
3
+ const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(body));
4
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
5
+ }
6
+ function etag(options = {}) {
7
+ return async (ctx, next) => {
8
+ await next();
9
+ const { response } = ctx;
10
+ if (response.status < 200 || response.status >= 300) return;
11
+ if (typeof response.body !== "string") return;
12
+ if (response.headers.get("etag") !== null) return;
13
+ const tag = `${options.weak ? "W/" : ""}"${await hash(response.body)}"`;
14
+ response.headers.set("etag", tag);
15
+ const ifNoneMatch = ctx.request.headers.get("if-none-match");
16
+ if (ifNoneMatch !== null && ifNoneMatch.split(/\s*,\s*/).includes(tag)) {
17
+ response.status = 304;
18
+ response.statusText = "Not Modified";
19
+ response.body = null;
20
+ response.headers.delete("content-length");
21
+ }
22
+ };
23
+ }
24
+ //#endregion
25
+ export { etag };
@@ -0,0 +1,35 @@
1
+ import { Middleware } from "@rhythmjs/rhythm";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/session/session.d.ts
4
+ export type SessionData = Record<string, unknown>;
5
+ export interface SessionStore {
6
+ get(id: string): Promise<SessionData | undefined> | SessionData | undefined;
7
+ set(id: string, data: SessionData, maxAge: number): Promise<void> | void;
8
+ delete(id: string): Promise<void> | void;
9
+ }
10
+ export declare class MemoryStore implements SessionStore {
11
+ #private;
12
+ get(id: string): SessionData | undefined;
13
+ set(id: string, data: SessionData, maxAge: number): void;
14
+ delete(id: string): void;
15
+ }
16
+ export interface Session {
17
+ readonly id: string;
18
+ get<T = unknown>(key: string): T | undefined;
19
+ set(key: string, value: unknown): void;
20
+ delete(key: string): void;
21
+ destroy(): void;
22
+ }
23
+ export type SessionContext = {
24
+ session: Session;
25
+ };
26
+ export interface SessionOptions {
27
+ store?: SessionStore;
28
+ cookieName?: string;
29
+ maxAge?: number;
30
+ path?: string;
31
+ secure?: boolean;
32
+ sameSite?: "strict" | "lax" | "none";
33
+ }
34
+ export declare function session(options?: SessionOptions): Middleware<RhythmHttpContext>;
35
+ //#endregion
@@ -0,0 +1,77 @@
1
+ //#region src/session/session.ts
2
+ var MemoryStore = class {
3
+ #entries = /* @__PURE__ */ new Map();
4
+ get(id) {
5
+ const entry = this.#entries.get(id);
6
+ if (!entry) return void 0;
7
+ if (entry.expires <= Date.now()) {
8
+ this.#entries.delete(id);
9
+ return;
10
+ }
11
+ return entry.data;
12
+ }
13
+ set(id, data, maxAge) {
14
+ this.#entries.set(id, {
15
+ data,
16
+ expires: Date.now() + maxAge * 1e3
17
+ });
18
+ }
19
+ delete(id) {
20
+ this.#entries.delete(id);
21
+ }
22
+ };
23
+ function readCookie(header, name) {
24
+ if (!header) return void 0;
25
+ for (const part of header.split(/;\s*/)) {
26
+ const eq = part.indexOf("=");
27
+ if (eq === -1) continue;
28
+ if (part.slice(0, eq).trim() !== name) continue;
29
+ try {
30
+ return decodeURIComponent(part.slice(eq + 1).trim());
31
+ } catch {
32
+ return part.slice(eq + 1).trim();
33
+ }
34
+ }
35
+ }
36
+ function session(options = {}) {
37
+ const store = options.store ?? new MemoryStore();
38
+ const cookieName = options.cookieName ?? "sid";
39
+ const maxAge = options.maxAge ?? 86400;
40
+ const path = options.path ?? "/";
41
+ const sameSite = options.sameSite ?? "lax";
42
+ const baseAttributes = `; Path=${path}; HttpOnly; SameSite=${`${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`}${options.secure ? "; Secure" : ""}`;
43
+ return async (ctx, next) => {
44
+ const incomingId = readCookie(ctx.request.headers.get("cookie"), cookieName);
45
+ const existing = incomingId === void 0 ? void 0 : await store.get(incomingId);
46
+ const id = existing === void 0 ? crypto.randomUUID() : incomingId;
47
+ const data = { ...existing };
48
+ let dirty = false;
49
+ let destroyed = false;
50
+ await next({ session: {
51
+ id,
52
+ get: (key) => data[key],
53
+ set: (key, value) => {
54
+ data[key] = value;
55
+ dirty = true;
56
+ },
57
+ delete: (key) => {
58
+ delete data[key];
59
+ dirty = true;
60
+ },
61
+ destroy: () => {
62
+ destroyed = true;
63
+ }
64
+ } });
65
+ if (destroyed) {
66
+ if (incomingId !== void 0) await store.delete(incomingId);
67
+ ctx.response.headers.append("set-cookie", `${cookieName}=; Max-Age=0${baseAttributes}`);
68
+ return;
69
+ }
70
+ if (dirty) {
71
+ await store.set(id, data, maxAge);
72
+ if (existing === void 0) ctx.response.headers.append("set-cookie", `${cookieName}=${id}; Max-Age=${maxAge}${baseAttributes}`);
73
+ }
74
+ };
75
+ }
76
+ //#endregion
77
+ export { MemoryStore, session };
@@ -0,0 +1,5 @@
1
+ import { Middleware } from "@rhythmjs/rhythm";
2
+ import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
3
+ //#region src/timeout/timeout.d.ts
4
+ export declare function timeout(ms: number): Middleware<RhythmHttpContext>;
5
+ //#endregion
@@ -0,0 +1,26 @@
1
+ //#region src/timeout/timeout.ts
2
+ function timeout(ms) {
3
+ return async (ctx, next) => {
4
+ let timer;
5
+ const expired = new Promise((resolve) => {
6
+ timer = setTimeout(() => resolve("expired"), ms);
7
+ });
8
+ try {
9
+ const pending = next().then(() => "completed");
10
+ if (await Promise.race([pending, expired]) === "expired") {
11
+ pending.catch(() => void 0);
12
+ ctx.response.status = 504;
13
+ ctx.response.headers.set("content-type", "application/json");
14
+ ctx.response.body = JSON.stringify({
15
+ success: false,
16
+ status: 504,
17
+ message: "Gateway Timeout"
18
+ });
19
+ }
20
+ } finally {
21
+ clearTimeout(timer);
22
+ }
23
+ };
24
+ }
25
+ //#endregion
26
+ export { timeout };
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@rhythmjs/http",
3
+ "version": "0.0.1",
4
+ "description": "HTTP utility middleware (cookies, sessions, etag, timeout, body limit) for Rhythm routers and handlers.",
5
+ "keywords": [
6
+ "body-limit",
7
+ "cookies",
8
+ "etag",
9
+ "http",
10
+ "middleware",
11
+ "rhythm",
12
+ "session",
13
+ "timeout"
14
+ ],
15
+ "license": "ISC",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/rhythmjs/http.git"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "exports": {
26
+ "./body-limit": {
27
+ "types": "./dist/body-limit/body-limit.d.ts",
28
+ "default": "./dist/body-limit/body-limit.js"
29
+ },
30
+ "./cookies": {
31
+ "types": "./dist/cookies/cookies.d.ts",
32
+ "default": "./dist/cookies/cookies.js"
33
+ },
34
+ "./etag": {
35
+ "types": "./dist/etag/etag.d.ts",
36
+ "default": "./dist/etag/etag.js"
37
+ },
38
+ "./session": {
39
+ "types": "./dist/session/session.d.ts",
40
+ "default": "./dist/session/session.js"
41
+ },
42
+ "./timeout": {
43
+ "types": "./dist/timeout/timeout.d.ts",
44
+ "default": "./dist/timeout/timeout.js"
45
+ },
46
+ "./package.json": "./package.json"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "scripts": {
52
+ "build": "vp pack",
53
+ "prepublishOnly": "vp check && vp test && vp pack",
54
+ "typecheck": "tsc --noEmit",
55
+ "test": "vp test",
56
+ "lint": "vp lint",
57
+ "fmt": "vp fmt",
58
+ "fmt:check": "vp fmt --check",
59
+ "check": "vp check"
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "^26.6.3",
63
+ "typescript": "^7.0.2",
64
+ "vite-plus": "^1.0.0"
65
+ },
66
+ "peerDependencies": {
67
+ "@rhythmjs/rhythm": "^0.0.1",
68
+ "@rhythmjs/router": "^0.0.1"
69
+ },
70
+ "engines": {
71
+ "node": ">=20.19.0"
72
+ },
73
+ "packageManager": "pnpm@10.33.2"
74
+ }