@rhythmjs/http 0.0.1 → 0.0.3
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 +85 -0
- package/dist/body-limit/body-limit.d.ts +1 -1
- package/dist/cookies/cookies.d.ts +2 -2
- package/dist/cookies/cookies.js +4 -2
- package/dist/etag/etag.d.ts +1 -1
- package/dist/i18n/i18n.d.ts +46 -0
- package/dist/i18n/i18n.js +139 -0
- package/dist/request-scope/request-scope.d.ts +22 -0
- package/dist/request-scope/request-scope.js +54 -0
- package/dist/session/session.d.ts +2 -2
- package/dist/session/session.js +5 -3
- package/dist/timeout/timeout.d.ts +1 -1
- package/package.json +32 -15
package/README.md
CHANGED
|
@@ -77,6 +77,48 @@ new RhythmRouter().use(etag()).get("/report", (ctx) => {
|
|
|
77
77
|
- `etag({ weak: true })` emits `W/"..."` tags.
|
|
78
78
|
- Only successful (2xx) string bodies are tagged; a pre-set `ETag` is left alone.
|
|
79
79
|
|
|
80
|
+
## `@rhythmjs/http/i18n`
|
|
81
|
+
|
|
82
|
+
[i18next](https://www.i18next.com/) integration: detects the request language, exposes a
|
|
83
|
+
request-scoped translator on the context, and sets the `Content-Language` response header. The
|
|
84
|
+
middleware is typed and coupled structurally (`I18nInstanceLike`) rather than against i18next's own
|
|
85
|
+
types — every instance capability (`cloneInstance`, `getFixedT`, `changeLanguage`, `init`) is
|
|
86
|
+
feature-detected at runtime, so it works across i18next versions (optional peer dependency, any
|
|
87
|
+
version) and with any compatible instance.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import i18next from "i18next";
|
|
91
|
+
import { i18n, type I18nContext } from "@rhythmjs/http/i18n";
|
|
92
|
+
|
|
93
|
+
await i18next.init({
|
|
94
|
+
supportedLngs: ["en", "de"],
|
|
95
|
+
fallbackLng: "en",
|
|
96
|
+
resources: {
|
|
97
|
+
en: { translation: { greeting: "Hello {{name}}" } },
|
|
98
|
+
de: { translation: { greeting: "Hallo {{name}}" } },
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
new RhythmRouter().use<I18nContext>(i18n({ i18next })).get("/greet", (ctx) => {
|
|
103
|
+
ctx.response.body = ctx.t("greeting", { name: "Ada" }); // "Hallo Ada" for ?lng=de
|
|
104
|
+
});
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- `ctx.t` — the instance's own `t` type bound to the detected language (a real i18next instance
|
|
108
|
+
keeps its full `TFunction` typing); `ctx.language` — the resolved language; `ctx.i18n` — a
|
|
109
|
+
request-scoped clone when the instance supports `cloneInstance` (safe for `changeLanguage` per
|
|
110
|
+
request), otherwise a `getFixedT`-based fallback.
|
|
111
|
+
- Detection tries `?lng=` querystring, the `i18next` cookie, then `Accept-Language` (ordered by
|
|
112
|
+
quality, matched exactly or by base language against `supportedLngs`), falling back to
|
|
113
|
+
`fallbackLng`. Configure via `detection` — `order` (may include `"path"` for `/de/...`-style
|
|
114
|
+
prefixes with `lookupPath` as the segment index), `lookupQuerystring`, `lookupCookie`,
|
|
115
|
+
`supportedLanguages`, `fallbackLanguage`.
|
|
116
|
+
- `cacheCookie: true` persists the resolved language in the detection cookie
|
|
117
|
+
(`cacheCookieMaxAge` seconds, default one year); `contentLanguage: false` disables the response
|
|
118
|
+
header. An uninitialized instance is initialized on first request.
|
|
119
|
+
- The standalone `detectLanguage(request, options?)` and `parseAcceptLanguage(header)` helpers are
|
|
120
|
+
exported too.
|
|
121
|
+
|
|
80
122
|
## `@rhythmjs/http/timeout`
|
|
81
123
|
|
|
82
124
|
Fails requests that exceed a deadline with `504 { "success": false, "status": 504, "message": "Gateway Timeout" }`.
|
|
@@ -105,6 +147,49 @@ new RhythmRouter().use(bodyLimit(1024 * 1024)).post("/upload", async (ctx) => {
|
|
|
105
147
|
});
|
|
106
148
|
```
|
|
107
149
|
|
|
150
|
+
## `@rhythmjs/http/request-scope`
|
|
151
|
+
|
|
152
|
+
Opens a per-request scope (backed by
|
|
153
|
+
[`AsyncLocalStorage`](https://nodejs.org/api/async_context.html)) holding the current
|
|
154
|
+
`RhythmHttpContext` plus a request-local key-value store, readable anywhere in the call stack — loggers,
|
|
155
|
+
database helpers, service functions — without threading `ctx` through every signature.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { requestScope, RequestScope } from "@rhythmjs/http/request-scope";
|
|
159
|
+
|
|
160
|
+
new RhythmRouter()
|
|
161
|
+
.use(requestScope())
|
|
162
|
+
.use(async (_ctx, next) => {
|
|
163
|
+
RequestScope.set("requestId", crypto.randomUUID());
|
|
164
|
+
await next();
|
|
165
|
+
})
|
|
166
|
+
.get("/greet", (ctx) => {
|
|
167
|
+
ctx.response.body = greeting();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Anywhere else, no ctx parameter needed:
|
|
171
|
+
function greeting() {
|
|
172
|
+
const path = new URL(RequestScope.context().request.url).pathname;
|
|
173
|
+
return `Hello from ${path} (request ${RequestScope.get("requestId")})`;
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `requestScope()` — register it before any middleware or handler that uses `RequestScope`. Each request
|
|
178
|
+
gets its own scope; concurrent requests never see each other's context or store.
|
|
179
|
+
- `RequestScope.context<TContext>()` — the current context. Pass your extended context type (e.g.
|
|
180
|
+
`RequestScope.context<RhythmHttpContext & CookiesContext>()`) for typed access to properties added by
|
|
181
|
+
earlier middleware.
|
|
182
|
+
- `RequestScope.get(key)` / `set(key, value)` / `has(key)` / `delete(key)` — the request-local store, for
|
|
183
|
+
values that belong to the request but not on the context (request ids, loggers, the authenticated
|
|
184
|
+
user). Augment the `RequestScopeStore` interface via declaration merging to type your keys.
|
|
185
|
+
- `RequestScope.run(context, fn)` — open a scope manually, for tests and non-HTTP entry points that reuse
|
|
186
|
+
request-scoped helpers.
|
|
187
|
+
- Naming convention: bare accessors (`context()`, `get()`, `set()`, …) throw `RequestScopeError` when no
|
|
188
|
+
scope is active; `contextOrNull()` and `isActive()` never throw, for code that runs both inside and
|
|
189
|
+
outside requests.
|
|
190
|
+
- Requires an `AsyncLocalStorage`-capable runtime: Node.js, Bun, and Deno work out of the box; on
|
|
191
|
+
Cloudflare Workers enable the `nodejs_compat` (or `nodejs_als`) compatibility flag.
|
|
192
|
+
|
|
108
193
|
## Development
|
|
109
194
|
|
|
110
195
|
```sh
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Middleware } from "@rhythmjs/rhythm";
|
|
1
|
+
import { Middleware } from "@rhythmjs/rhythm/types";
|
|
2
2
|
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
3
|
//#region src/body-limit/body-limit.d.ts
|
|
4
4
|
export declare function bodyLimit(maxBytes: number): Middleware<RhythmHttpContext>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
|
|
2
2
|
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
3
|
//#region src/cookies/cookies.d.ts
|
|
4
4
|
export interface CookieOptions {
|
|
@@ -23,5 +23,5 @@ export declare class Cookies {
|
|
|
23
23
|
export type CookiesContext = {
|
|
24
24
|
cookies: Cookies;
|
|
25
25
|
};
|
|
26
|
-
export declare function cookies():
|
|
26
|
+
export declare function cookies(): DeriveMiddleware<RhythmHttpContext, CookiesContext>;
|
|
27
27
|
//#endregion
|
package/dist/cookies/cookies.js
CHANGED
|
@@ -56,9 +56,11 @@ var Cookies = class {
|
|
|
56
56
|
}
|
|
57
57
|
};
|
|
58
58
|
function cookies() {
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const middleware = async (ctx, next) => {
|
|
60
|
+
ctx.cookies = new Cookies(parseCookies(ctx.request.headers.get("cookie")), ctx.response.headers);
|
|
61
|
+
await next();
|
|
61
62
|
};
|
|
63
|
+
return middleware;
|
|
62
64
|
}
|
|
63
65
|
//#endregion
|
|
64
66
|
export { Cookies, cookies, parseCookies, serializeCookie };
|
package/dist/etag/etag.d.ts
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
|
|
2
|
+
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
|
+
//#region src/i18n/i18n.d.ts
|
|
4
|
+
export type DetectionSource = "querystring" | "cookie" | "header" | "path";
|
|
5
|
+
export interface LanguageDetectionOptions {
|
|
6
|
+
order?: DetectionSource[];
|
|
7
|
+
lookupQuerystring?: string;
|
|
8
|
+
lookupCookie?: string;
|
|
9
|
+
lookupPath?: number;
|
|
10
|
+
supportedLanguages?: string[];
|
|
11
|
+
fallbackLanguage?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface I18nInstanceLike {
|
|
14
|
+
t: (...args: any[]) => any;
|
|
15
|
+
isInitialized?: boolean;
|
|
16
|
+
language?: string;
|
|
17
|
+
resolvedLanguage?: string;
|
|
18
|
+
options?: {
|
|
19
|
+
supportedLngs?: unknown;
|
|
20
|
+
fallbackLng?: unknown;
|
|
21
|
+
};
|
|
22
|
+
init?: (...args: any[]) => any;
|
|
23
|
+
cloneInstance?: (...args: any[]) => any;
|
|
24
|
+
changeLanguage?: (...args: any[]) => any;
|
|
25
|
+
getFixedT?: (...args: any[]) => (...args: any[]) => any;
|
|
26
|
+
}
|
|
27
|
+
export interface I18nContext<TInstance extends I18nInstanceLike = I18nInstanceLike> {
|
|
28
|
+
i18n: TInstance;
|
|
29
|
+
t: TInstance["t"];
|
|
30
|
+
language: string;
|
|
31
|
+
}
|
|
32
|
+
export interface I18nOptions<TInstance extends I18nInstanceLike = I18nInstanceLike> {
|
|
33
|
+
i18next: TInstance;
|
|
34
|
+
detection?: LanguageDetectionOptions;
|
|
35
|
+
cacheCookie?: boolean;
|
|
36
|
+
cacheCookieMaxAge?: number;
|
|
37
|
+
contentLanguage?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface AcceptedLanguage {
|
|
40
|
+
language: string;
|
|
41
|
+
quality: number;
|
|
42
|
+
}
|
|
43
|
+
export declare function parseAcceptLanguage(header: string | null): AcceptedLanguage[];
|
|
44
|
+
export declare function detectLanguage(request: Request, options?: LanguageDetectionOptions): string | undefined;
|
|
45
|
+
export declare function i18n<TInstance extends I18nInstanceLike>(options: I18nOptions<TInstance>): DeriveMiddleware<RhythmHttpContext, I18nContext<TInstance>>;
|
|
46
|
+
//#endregion
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
//#region src/i18n/i18n.ts
|
|
2
|
+
function parseAcceptLanguage(header) {
|
|
3
|
+
if (!header) return [];
|
|
4
|
+
const accepted = [];
|
|
5
|
+
for (const part of header.split(",")) {
|
|
6
|
+
const [tag, ...params] = part.trim().split(";");
|
|
7
|
+
const language = tag?.trim();
|
|
8
|
+
if (!language) continue;
|
|
9
|
+
let quality = 1;
|
|
10
|
+
for (const param of params) {
|
|
11
|
+
const [key, value] = param.split("=");
|
|
12
|
+
if (key?.trim().toLowerCase() !== "q") continue;
|
|
13
|
+
const parsed = Number.parseFloat(value ?? "");
|
|
14
|
+
if (!Number.isNaN(parsed)) quality = Math.min(Math.max(parsed, 0), 1);
|
|
15
|
+
}
|
|
16
|
+
if (quality > 0) accepted.push({
|
|
17
|
+
language,
|
|
18
|
+
quality
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return accepted.sort((a, b) => b.quality - a.quality);
|
|
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 candidatesFrom(request, options) {
|
|
37
|
+
const order = options.order ?? [
|
|
38
|
+
"querystring",
|
|
39
|
+
"cookie",
|
|
40
|
+
"header"
|
|
41
|
+
];
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
for (const source of order) {
|
|
44
|
+
let candidates = [];
|
|
45
|
+
if (source === "querystring") {
|
|
46
|
+
const value = url.searchParams.get(options.lookupQuerystring ?? "lng");
|
|
47
|
+
if (value) candidates = [value];
|
|
48
|
+
} else if (source === "cookie") {
|
|
49
|
+
const value = readCookie(request.headers.get("cookie"), options.lookupCookie ?? "i18next");
|
|
50
|
+
if (value) candidates = [value];
|
|
51
|
+
} else if (source === "path") {
|
|
52
|
+
const value = url.pathname.split("/").filter((segment) => segment !== "")[options.lookupPath ?? 0];
|
|
53
|
+
if (value) candidates = [value];
|
|
54
|
+
} else candidates = parseAcceptLanguage(request.headers.get("accept-language")).map((entry) => entry.language).filter((language) => language !== "*");
|
|
55
|
+
if (candidates.length > 0) return candidates;
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
function matchLanguage(candidates, supported) {
|
|
60
|
+
if (supported === void 0 || supported.length === 0) return candidates[0];
|
|
61
|
+
const byLower = new Map(supported.map((language) => [language.toLowerCase(), language]));
|
|
62
|
+
for (const candidate of candidates) {
|
|
63
|
+
const lower = candidate.toLowerCase();
|
|
64
|
+
const exact = byLower.get(lower);
|
|
65
|
+
if (exact !== void 0) return exact;
|
|
66
|
+
const base = byLower.get(lower.split("-")[0]);
|
|
67
|
+
if (base !== void 0) return base;
|
|
68
|
+
const baseOfSupported = supported.find((language) => language.toLowerCase().split("-")[0] === lower);
|
|
69
|
+
if (baseOfSupported !== void 0) return baseOfSupported;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function detectLanguage(request, options = {}) {
|
|
73
|
+
return matchLanguage(candidatesFrom(request, options), options.supportedLanguages) ?? options.fallbackLanguage;
|
|
74
|
+
}
|
|
75
|
+
function supportedFromInstance(instance) {
|
|
76
|
+
const supported = instance.options?.supportedLngs;
|
|
77
|
+
if (!Array.isArray(supported)) return void 0;
|
|
78
|
+
const languages = supported.filter((language) => typeof language === "string" && language !== "cimode");
|
|
79
|
+
return languages.length > 0 ? languages : void 0;
|
|
80
|
+
}
|
|
81
|
+
function fallbackFromInstance(instance) {
|
|
82
|
+
const fallback = instance.options?.fallbackLng;
|
|
83
|
+
if (typeof fallback === "string") return fallback;
|
|
84
|
+
if (Array.isArray(fallback) && typeof fallback[0] === "string") return fallback[0];
|
|
85
|
+
}
|
|
86
|
+
function isInstanceLike(value) {
|
|
87
|
+
return typeof value === "object" && value !== null && typeof value.t === "function";
|
|
88
|
+
}
|
|
89
|
+
async function scopeInstance(instance, language) {
|
|
90
|
+
if (typeof instance.cloneInstance === "function") {
|
|
91
|
+
const clone = instance.cloneInstance();
|
|
92
|
+
if (isInstanceLike(clone)) {
|
|
93
|
+
const scoped = clone;
|
|
94
|
+
if (language !== void 0 && language !== scoped.language && typeof scoped.changeLanguage === "function") await scoped.changeLanguage(language);
|
|
95
|
+
return {
|
|
96
|
+
scoped,
|
|
97
|
+
translate: scoped.t.bind(scoped),
|
|
98
|
+
resolved: scoped.resolvedLanguage ?? scoped.language ?? language ?? ""
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (language !== void 0 && typeof instance.getFixedT === "function") return {
|
|
103
|
+
scoped: instance,
|
|
104
|
+
translate: instance.getFixedT(language),
|
|
105
|
+
resolved: language
|
|
106
|
+
};
|
|
107
|
+
if (language !== void 0 && language !== instance.language && typeof instance.changeLanguage === "function") await instance.changeLanguage(language);
|
|
108
|
+
return {
|
|
109
|
+
scoped: instance,
|
|
110
|
+
translate: instance.t.bind(instance),
|
|
111
|
+
resolved: instance.resolvedLanguage ?? instance.language ?? language ?? ""
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function i18n(options) {
|
|
115
|
+
const instance = options.i18next;
|
|
116
|
+
const detection = options.detection ?? {};
|
|
117
|
+
const cookieName = detection.lookupCookie ?? "i18next";
|
|
118
|
+
const cacheCookieMaxAge = options.cacheCookieMaxAge ?? 31536e3;
|
|
119
|
+
let initializing;
|
|
120
|
+
const middleware = async (ctx, next) => {
|
|
121
|
+
if (instance.isInitialized !== true && typeof instance.init === "function") {
|
|
122
|
+
initializing ??= Promise.resolve(instance.init());
|
|
123
|
+
await initializing;
|
|
124
|
+
}
|
|
125
|
+
const supported = detection.supportedLanguages ?? supportedFromInstance(instance);
|
|
126
|
+
const fallback = detection.fallbackLanguage ?? fallbackFromInstance(instance);
|
|
127
|
+
const language = matchLanguage(candidatesFrom(ctx.request, detection), supported) ?? fallback;
|
|
128
|
+
const { scoped, translate, resolved } = await scopeInstance(instance, language);
|
|
129
|
+
ctx.i18n = scoped;
|
|
130
|
+
ctx.t = translate;
|
|
131
|
+
ctx.language = resolved;
|
|
132
|
+
await next();
|
|
133
|
+
if (options.cacheCookie === true && resolved !== "" && readCookie(ctx.request.headers.get("cookie"), cookieName) !== resolved) ctx.response.headers.append("set-cookie", `${cookieName}=${encodeURIComponent(resolved)}; Max-Age=${cacheCookieMaxAge}; Path=/; SameSite=Lax`);
|
|
134
|
+
if (options.contentLanguage !== false && resolved !== "" && ctx.response.headers.get("content-language") === null) ctx.response.headers.set("content-language", resolved);
|
|
135
|
+
};
|
|
136
|
+
return middleware;
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { detectLanguage, i18n, parseAcceptLanguage };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Middleware } from "@rhythmjs/rhythm/types";
|
|
2
|
+
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
|
+
//#region src/request-scope/request-scope.d.ts
|
|
4
|
+
export interface RequestScopeStore {}
|
|
5
|
+
export declare class RequestScopeError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export declare function requestScope<TContext extends RhythmHttpContext = RhythmHttpContext>(): Middleware<TContext>;
|
|
9
|
+
export declare class RequestScope {
|
|
10
|
+
private constructor();
|
|
11
|
+
static isActive(): boolean;
|
|
12
|
+
static context<TContext extends RhythmHttpContext = RhythmHttpContext>(): TContext;
|
|
13
|
+
static contextOrNull<TContext extends RhythmHttpContext = RhythmHttpContext>(): TContext | null;
|
|
14
|
+
static get<K extends keyof RequestScopeStore>(key: K): RequestScopeStore[K] | undefined;
|
|
15
|
+
static get<T = unknown>(key: string | symbol): T | undefined;
|
|
16
|
+
static set<K extends keyof RequestScopeStore>(key: K, value: RequestScopeStore[K]): void;
|
|
17
|
+
static set<T = unknown>(key: string | symbol, value: T): void;
|
|
18
|
+
static has(key: string | symbol): boolean;
|
|
19
|
+
static delete(key: string | symbol): boolean;
|
|
20
|
+
static run<TContext extends RhythmHttpContext, TResult>(context: TContext, fn: () => TResult): TResult;
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
//#region src/request-scope/request-scope.ts
|
|
3
|
+
var RequestScopeError = class extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "RequestScopeError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
const frames = new AsyncLocalStorage();
|
|
10
|
+
function activeFrame(operation) {
|
|
11
|
+
const frame = frames.getStore();
|
|
12
|
+
if (frame === void 0) throw new RequestScopeError(`RequestScope.${operation}() was called outside an active request scope. Register the requestScope() middleware before anything that uses RequestScope, or open a scope manually with RequestScope.run().`);
|
|
13
|
+
return frame;
|
|
14
|
+
}
|
|
15
|
+
function requestScope() {
|
|
16
|
+
return async (ctx, next) => {
|
|
17
|
+
await frames.run({
|
|
18
|
+
context: ctx,
|
|
19
|
+
store: /* @__PURE__ */ new Map()
|
|
20
|
+
}, next);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
var RequestScope = class {
|
|
24
|
+
constructor() {}
|
|
25
|
+
static isActive() {
|
|
26
|
+
return frames.getStore() !== void 0;
|
|
27
|
+
}
|
|
28
|
+
static context() {
|
|
29
|
+
return activeFrame("context").context;
|
|
30
|
+
}
|
|
31
|
+
static contextOrNull() {
|
|
32
|
+
return frames.getStore()?.context ?? null;
|
|
33
|
+
}
|
|
34
|
+
static get(key) {
|
|
35
|
+
return activeFrame("get").store.get(key);
|
|
36
|
+
}
|
|
37
|
+
static set(key, value) {
|
|
38
|
+
activeFrame("set").store.set(key, value);
|
|
39
|
+
}
|
|
40
|
+
static has(key) {
|
|
41
|
+
return activeFrame("has").store.has(key);
|
|
42
|
+
}
|
|
43
|
+
static delete(key) {
|
|
44
|
+
return activeFrame("delete").store.delete(key);
|
|
45
|
+
}
|
|
46
|
+
static run(context, fn) {
|
|
47
|
+
return frames.run({
|
|
48
|
+
context,
|
|
49
|
+
store: /* @__PURE__ */ new Map()
|
|
50
|
+
}, fn);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { RequestScope, RequestScopeError, requestScope };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DeriveMiddleware } from "@rhythmjs/rhythm/types";
|
|
2
2
|
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
3
|
//#region src/session/session.d.ts
|
|
4
4
|
export type SessionData = Record<string, unknown>;
|
|
@@ -31,5 +31,5 @@ export interface SessionOptions {
|
|
|
31
31
|
secure?: boolean;
|
|
32
32
|
sameSite?: "strict" | "lax" | "none";
|
|
33
33
|
}
|
|
34
|
-
export declare function session(options?: SessionOptions):
|
|
34
|
+
export declare function session(options?: SessionOptions): DeriveMiddleware<RhythmHttpContext, SessionContext>;
|
|
35
35
|
//#endregion
|
package/dist/session/session.js
CHANGED
|
@@ -40,14 +40,14 @@ function session(options = {}) {
|
|
|
40
40
|
const path = options.path ?? "/";
|
|
41
41
|
const sameSite = options.sameSite ?? "lax";
|
|
42
42
|
const baseAttributes = `; Path=${path}; HttpOnly; SameSite=${`${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`}${options.secure ? "; Secure" : ""}`;
|
|
43
|
-
|
|
43
|
+
const middleware = async (ctx, next) => {
|
|
44
44
|
const incomingId = readCookie(ctx.request.headers.get("cookie"), cookieName);
|
|
45
45
|
const existing = incomingId === void 0 ? void 0 : await store.get(incomingId);
|
|
46
46
|
const id = existing === void 0 ? crypto.randomUUID() : incomingId;
|
|
47
47
|
const data = { ...existing };
|
|
48
48
|
let dirty = false;
|
|
49
49
|
let destroyed = false;
|
|
50
|
-
|
|
50
|
+
ctx.session = {
|
|
51
51
|
id,
|
|
52
52
|
get: (key) => data[key],
|
|
53
53
|
set: (key, value) => {
|
|
@@ -61,7 +61,8 @@ function session(options = {}) {
|
|
|
61
61
|
destroy: () => {
|
|
62
62
|
destroyed = true;
|
|
63
63
|
}
|
|
64
|
-
}
|
|
64
|
+
};
|
|
65
|
+
await next();
|
|
65
66
|
if (destroyed) {
|
|
66
67
|
if (incomingId !== void 0) await store.delete(incomingId);
|
|
67
68
|
ctx.response.headers.append("set-cookie", `${cookieName}=; Max-Age=0${baseAttributes}`);
|
|
@@ -72,6 +73,7 @@ function session(options = {}) {
|
|
|
72
73
|
if (existing === void 0) ctx.response.headers.append("set-cookie", `${cookieName}=${id}; Max-Age=${maxAge}${baseAttributes}`);
|
|
73
74
|
}
|
|
74
75
|
};
|
|
76
|
+
return middleware;
|
|
75
77
|
}
|
|
76
78
|
//#endregion
|
|
77
79
|
export { MemoryStore, session };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Middleware } from "@rhythmjs/rhythm";
|
|
1
|
+
import { Middleware } from "@rhythmjs/rhythm/types";
|
|
2
2
|
import { RhythmHttpContext } from "@rhythmjs/router/adapters/context";
|
|
3
3
|
//#region src/timeout/timeout.d.ts
|
|
4
4
|
export declare function timeout(ms: number): Middleware<RhythmHttpContext>;
|
package/package.json
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhythmjs/http",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "HTTP utility middleware (cookies, sessions, etag, timeout, body limit) for Rhythm routers and handlers.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"async-local-storage",
|
|
6
7
|
"body-limit",
|
|
7
8
|
"cookies",
|
|
8
9
|
"etag",
|
|
9
10
|
"http",
|
|
11
|
+
"i18n",
|
|
12
|
+
"i18next",
|
|
10
13
|
"middleware",
|
|
14
|
+
"request-scope",
|
|
11
15
|
"rhythm",
|
|
12
16
|
"session",
|
|
13
17
|
"timeout"
|
|
@@ -35,6 +39,14 @@
|
|
|
35
39
|
"types": "./dist/etag/etag.d.ts",
|
|
36
40
|
"default": "./dist/etag/etag.js"
|
|
37
41
|
},
|
|
42
|
+
"./i18n": {
|
|
43
|
+
"types": "./dist/i18n/i18n.d.ts",
|
|
44
|
+
"default": "./dist/i18n/i18n.js"
|
|
45
|
+
},
|
|
46
|
+
"./request-scope": {
|
|
47
|
+
"types": "./dist/request-scope/request-scope.d.ts",
|
|
48
|
+
"default": "./dist/request-scope/request-scope.js"
|
|
49
|
+
},
|
|
38
50
|
"./session": {
|
|
39
51
|
"types": "./dist/session/session.d.ts",
|
|
40
52
|
"default": "./dist/session/session.js"
|
|
@@ -48,27 +60,32 @@
|
|
|
48
60
|
"publishConfig": {
|
|
49
61
|
"access": "public"
|
|
50
62
|
},
|
|
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
63
|
"devDependencies": {
|
|
62
64
|
"@types/node": "^26.6.3",
|
|
65
|
+
"i18next": "^26.4.2",
|
|
63
66
|
"typescript": "^7.0.2",
|
|
64
67
|
"vite-plus": "^1.0.0"
|
|
65
68
|
},
|
|
66
69
|
"peerDependencies": {
|
|
67
|
-
"@rhythmjs/rhythm": "^0.0.
|
|
68
|
-
"@rhythmjs/router": "^0.0.
|
|
70
|
+
"@rhythmjs/rhythm": "^0.0.5",
|
|
71
|
+
"@rhythmjs/router": "^0.0.5",
|
|
72
|
+
"i18next": "*"
|
|
73
|
+
},
|
|
74
|
+
"peerDependenciesMeta": {
|
|
75
|
+
"i18next": {
|
|
76
|
+
"optional": true
|
|
77
|
+
}
|
|
69
78
|
},
|
|
70
79
|
"engines": {
|
|
71
80
|
"node": ">=20.19.0"
|
|
72
81
|
},
|
|
73
|
-
"
|
|
74
|
-
|
|
82
|
+
"scripts": {
|
|
83
|
+
"build": "vp pack",
|
|
84
|
+
"typecheck": "tsc --noEmit",
|
|
85
|
+
"test": "vp test",
|
|
86
|
+
"lint": "vp lint",
|
|
87
|
+
"fmt": "vp fmt",
|
|
88
|
+
"fmt:check": "vp fmt --check",
|
|
89
|
+
"check": "vp check"
|
|
90
|
+
}
|
|
91
|
+
}
|