@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 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 { Middleware } from "@rhythmjs/rhythm";
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(): Middleware<RhythmHttpContext>;
26
+ export declare function cookies(): DeriveMiddleware<RhythmHttpContext, CookiesContext>;
27
27
  //#endregion
@@ -56,9 +56,11 @@ var Cookies = class {
56
56
  }
57
57
  };
58
58
  function cookies() {
59
- return async (ctx, next) => {
60
- await next({ cookies: new Cookies(parseCookies(ctx.request.headers.get("cookie")), ctx.response.headers) });
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 };
@@ -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/etag/etag.d.ts
4
4
  export interface EtagOptions {
@@ -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 { Middleware } from "@rhythmjs/rhythm";
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): Middleware<RhythmHttpContext>;
34
+ export declare function session(options?: SessionOptions): DeriveMiddleware<RhythmHttpContext, SessionContext>;
35
35
  //#endregion
@@ -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
- return async (ctx, next) => {
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
- await next({ session: {
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.1",
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.1",
68
- "@rhythmjs/router": "^0.0.1"
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
- "packageManager": "pnpm@10.33.2"
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
+ }