Initial Commit
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# `I18nMiddleware`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Normalises locale-related metadata across the chain. Injects an `x-locale` header, sets `x-direction: rtl` for right-to-left scripts, and (when configured with a clock) rewrites the envelope's timestamp to a single reference frame so peers in different timezones do not produce comparison-resistant timestamps.
|
||||
|
||||
## Features
|
||||
|
||||
- BCP-47 locale tag carried in the `x-locale` header so receivers can switch translations / formatters without inspecting the payload.
|
||||
- RTL detection via configurable locale-prefix list (defaults to `ar`, `he`, `fa`, `ur` — covers the bulk of right-to-left scripts).
|
||||
- Optional UTC normalisation: callers pass a `clock` (typically `Date.now`) and the middleware rewrites `envelope.timestamp` so cross-host comparisons stay reliable.
|
||||
- Lives in the NORMAL band by default (`priority: 280`) so locale headers are present before transformation / metrics observe them.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
I18nMiddleware,
|
||||
MiddlewareManager,
|
||||
} from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
const manager = new MiddlewareManager();
|
||||
manager.register(new I18nMiddleware({ locale: "ar-EG" }));
|
||||
const result = await manager.dispatch(envelope, "outgoing");
|
||||
result.envelope?.headers["x-locale"]; // "ar-EG"
|
||||
result.envelope?.headers["x-direction"]; // "rtl"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { I18nMiddleware } from "@suaveplan/network-plugin-middleware";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const i18n = new I18nMiddleware({
|
||||
id: "i18n-utc",
|
||||
priority: 290,
|
||||
locale: "en-US",
|
||||
rtlLocales: ["ar", "he", "fa", "ur", "yi"],
|
||||
clock: () => now,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class I18nMiddleware implements Middleware`
|
||||
- `constructor(config?: I18nMiddlewareConfig)`.
|
||||
- `apply(envelope, context): MessageEnvelope` — never drops.
|
||||
- `interface I18nMiddlewareConfig` — `{ id?, priority?, direction?, locale?, rtlLocales?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- RTL detection compares the lower-cased locale tag against each prefix. A locale matches the prefix exactly (`"ar"`) or hyphen-bounded (`"ar-EG"`); `"argonaut"` does NOT match the `ar` prefix because the comparator only allows the empty suffix or a `-` separator.
|
||||
- The middleware always returns a new envelope object; even when it leaves the headers untouched, callers can rely on identity-equality being a useful "did i18n run?" signal.
|
||||
- The clock rewrite is opt-in: applications that already enforce monotonic timestamps elsewhere (e.g. via `network-plugin-protocol`) should leave it unset to avoid double-normalisation.
|
||||
- `x-locale` is the conventional WebSocket extension header, deliberately reused so subscribers (logger, metrics) do not need to learn a new key per protocol surface.
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { MessageEnvelope } from "@suaveplan/network";
|
||||
import type {
|
||||
Middleware,
|
||||
MiddlewareContext,
|
||||
} from "../types/middleware.types.js";
|
||||
|
||||
export interface I18nMiddlewareConfig {
|
||||
readonly id?: string;
|
||||
readonly priority?: number;
|
||||
readonly direction?: MiddlewareContext["direction"];
|
||||
/** BCP-47 locale tag (default `"en"`). */
|
||||
readonly locale?: string;
|
||||
/** Set of locale prefixes considered RTL (default `["ar", "he", "fa", "ur"]`). */
|
||||
readonly rtlLocales?: ReadonlyArray<string>;
|
||||
/** Optional clock — when set, the middleware rewrites `envelope.timestamp` to `clock()`. */
|
||||
readonly clock?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_RTL = Object.freeze(["ar", "he", "fa", "ur"]);
|
||||
|
||||
/**
|
||||
* Locale-aware envelope normalisation: injects `x-locale` and (when
|
||||
* applicable) `x-direction: rtl` headers, and optionally normalises
|
||||
* `envelope.timestamp` to a caller-supplied clock so timestamps from
|
||||
* peers in other timezones land in a single reference frame.
|
||||
*/
|
||||
export class I18nMiddleware implements Middleware {
|
||||
public readonly id: string;
|
||||
public readonly priority: number;
|
||||
public readonly direction: MiddlewareContext["direction"] | undefined;
|
||||
public enabled = true;
|
||||
private readonly locale: string;
|
||||
private readonly rtlPrefixes: ReadonlyArray<string>;
|
||||
private readonly clock: (() => number) | undefined;
|
||||
|
||||
public constructor(config: I18nMiddlewareConfig = {}) {
|
||||
this.id = config.id ?? "i18n";
|
||||
this.priority = config.priority ?? 280;
|
||||
this.direction = config.direction;
|
||||
this.locale = config.locale ?? "en";
|
||||
this.rtlPrefixes = config.rtlLocales ?? DEFAULT_RTL;
|
||||
this.clock = config.clock;
|
||||
}
|
||||
|
||||
public apply(
|
||||
envelope: MessageEnvelope,
|
||||
_context: MiddlewareContext
|
||||
): MessageEnvelope {
|
||||
const headers = { ...envelope.headers };
|
||||
headers["x-locale"] = this.locale;
|
||||
if (this.isRtl(this.locale)) {
|
||||
headers["x-direction"] = "rtl";
|
||||
}
|
||||
const next: MessageEnvelope = {
|
||||
...envelope,
|
||||
headers,
|
||||
};
|
||||
if (this.clock !== undefined) {
|
||||
return { ...next, timestamp: this.clock() };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private isRtl(locale: string): boolean {
|
||||
const lower = locale.toLowerCase();
|
||||
for (const prefix of this.rtlPrefixes) {
|
||||
if (lower === prefix || lower.startsWith(`${prefix}-`)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createEnvelope } from "@suaveplan/network";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { I18nMiddleware } from "./i18n-middleware.js";
|
||||
|
||||
const env = () =>
|
||||
createEnvelope({ type: "i18n:test", senderId: "peer-1", payload: {} });
|
||||
|
||||
const ctx = {
|
||||
direction: "outgoing" as const,
|
||||
state: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
describe("I18nMiddleware: locale headers, RTL, UTC normalisation", () => {
|
||||
it("injects x-locale header (default en, no x-direction)", () => {
|
||||
const i = new I18nMiddleware();
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-locale"]).toBe("en");
|
||||
expect(result.headers["x-direction"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits x-direction: rtl for RTL locales", () => {
|
||||
const i = new I18nMiddleware({ locale: "ar-EG" });
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-direction"]).toBe("rtl");
|
||||
});
|
||||
|
||||
it("matches RTL prefix exactly (no false positives)", () => {
|
||||
const i = new I18nMiddleware({ locale: "argonaut" });
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-direction"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("matches when locale equals an RTL prefix exactly", () => {
|
||||
const i = new I18nMiddleware({ locale: "he" });
|
||||
const result = i.apply(env(), ctx);
|
||||
expect(result.headers["x-direction"]).toBe("rtl");
|
||||
});
|
||||
|
||||
it("rewrites timestamp when clock is provided", () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const i = new I18nMiddleware({ clock: () => now });
|
||||
const e = env();
|
||||
const before = e.timestamp;
|
||||
const result = i.apply(e, ctx);
|
||||
expect(result.timestamp).toBe(now);
|
||||
expect(result.timestamp).not.toBe(before);
|
||||
});
|
||||
|
||||
it("custom rtlLocales overrides defaults", () => {
|
||||
const i = new I18nMiddleware({
|
||||
locale: "yi",
|
||||
rtlLocales: ["yi"],
|
||||
});
|
||||
expect(i.apply(env(), ctx).headers["x-direction"]).toBe("rtl");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user