okengine 0.5.0 → 0.6.0

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.
Files changed (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
@@ -56,13 +56,22 @@ describe("agent gate denial is recorded", () => {
56
56
 
57
57
  const refund = ai.agent("support", {
58
58
  tools: ["bookings.refundBooking"],
59
- maxSteps: 2,
59
+ maxSteps: 1,
60
+ model: "smart",
60
61
  });
61
62
 
62
63
  const called: string[] = [];
63
64
  const runtime = createAiRuntime({
65
+ models: [ai.model("smart")],
64
66
  agents: [refund],
65
67
  gates,
68
+ defaultDriver: createMockAiDriver({
69
+ "*": {
70
+ __toolCalls: [
71
+ { id: "c1", name: "bookings.refundBooking", arguments: { reason: "customer" } },
72
+ ],
73
+ },
74
+ }),
66
75
  gatesForFlow: (name) => (name === "bookings.refundBooking" ? ["member"] : []),
67
76
  callFlow: async (name) => {
68
77
  called.push(name);
@@ -87,8 +96,16 @@ describe("agent gate denial is recorded", () => {
87
96
  const member = gate.policy("member", ({ auth }) => !!auth.verified);
88
97
  const gates = createGateRuntime({ gates: [member] });
89
98
  const runtime = createAiRuntime({
90
- agents: [ai.agent("support", { tools: ["bookings.getBooking"], maxSteps: 1 })],
99
+ models: [ai.model("smart")],
100
+ agents: [
101
+ ai.agent("support", { tools: ["bookings.getBooking"], maxSteps: 1, model: "smart" }),
102
+ ],
91
103
  gates,
104
+ defaultDriver: createMockAiDriver({
105
+ "*": {
106
+ __toolCalls: [{ id: "c1", name: "bookings.getBooking", arguments: {} }],
107
+ },
108
+ }),
92
109
  gatesForFlow: () => ["member"],
93
110
  callFlow: async () => ({ booking: "B1" }),
94
111
  });
@@ -367,13 +384,20 @@ describe("agent tool trail carries effects; denials are not errors", () => {
367
384
  const member = gate.policy("member", ({ auth }) => !!auth.verified);
368
385
  const gates = createGateRuntime({ gates: [member] });
369
386
  const runtime = createAiRuntime({
387
+ models: [ai.model("smart")],
370
388
  agents: [
371
389
  ai.agent("support", {
372
390
  tools: ["bookings.refundBooking"],
373
391
  maxSteps: 1,
392
+ model: "smart",
374
393
  }),
375
394
  ],
376
395
  gates,
396
+ defaultDriver: createMockAiDriver({
397
+ "*": {
398
+ __toolCalls: [{ id: "c1", name: "bookings.refundBooking", arguments: {} }],
399
+ },
400
+ }),
377
401
  gatesForFlow: () => ["member"],
378
402
  effectsForFlow: (name) =>
379
403
  name === "bookings.refundBooking"
@@ -25,7 +25,12 @@ export type {
25
25
  AiPromptOptions,
26
26
  } from "./ai/declare.ts";
27
27
 
28
- export { createAiRuntime, AiSchemaValidationError } from "./ai/runtime.ts";
28
+ export {
29
+ createAiRuntime,
30
+ AiSchemaValidationError,
31
+ promptContentFromInput,
32
+ AI_DEFAULT_MAX_STEPS,
33
+ } from "./ai/runtime.ts";
29
34
  export type {
30
35
  AgentDenial,
31
36
  AgentRunRecord,
@@ -38,9 +43,13 @@ export type {
38
43
  AiJournalEntry,
39
44
  AiRuntime,
40
45
  AiSchemaMismatch,
46
+ AiStreamOptions,
41
47
  CreateAiRuntimeOptions,
42
48
  } from "./ai/runtime.ts";
43
49
 
50
+ export { AI_RATE_PRESETS, aiRateGate, createAiRateGates } from "./ai/rate.ts";
51
+ export type { AiRatePreset } from "./ai/rate.ts";
52
+
44
53
  export { assertAllowPiiForAsk, AiPiiBuildError, type PiiAskCheckInput } from "./ai/pii.ts";
45
54
 
46
55
  export {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Built-in Arabic messages — framework OKE codes + typed failure messages.
3
+ */
4
+
5
+ export const builtinAr = {
6
+ oke: {
7
+ "1001": {
8
+ cause: 'التدفق "{flow}" يقرأ "{resource}" دون الإعلان عنه.',
9
+ fix: 'أضف "{resource}" إلى effects.reads لهذا التدفق.',
10
+ },
11
+ "1002": {
12
+ cause: 'التدفق "{flow}" يكتب "{resource}" دون الإعلان عنه.',
13
+ fix: 'أضف "{resource}" إلى effects.writes لهذا التدفق.',
14
+ },
15
+ "1003": {
16
+ cause: 'التدفق "{flow}" يُصدِر "{resource}" دون الإعلان عنه.',
17
+ fix: 'أضف "{resource}" إلى effects.emits لهذا التدفق.',
18
+ },
19
+ "1004": {
20
+ cause: 'التدفق "{flow}" يرسل "{resource}" دون الإعلان عنه.',
21
+ fix: 'أضف "{resource}" إلى effects.sends لهذا التدفق.',
22
+ },
23
+ "1005": {
24
+ cause: 'التدفق "{flow}" يستدعي النموذج "{resource}" دون الإعلان عنه.',
25
+ fix: 'أضف "{resource}" إلى effects.asks لهذا التدفق.',
26
+ },
27
+ "1006": {
28
+ cause: 'التدفق "{flow}" يقرأ السر "{resource}" دون الإعلان عنه.',
29
+ fix: 'أضف "{resource}" إلى effects.secrets لهذا التدفق.',
30
+ },
31
+ "1007": {
32
+ cause: 'التدفق "{flow}" يستدعي التدفق "{resource}" دون الإعلان عنه.',
33
+ fix: 'أضف "{resource}" إلى effects.calls لهذا التدفق.',
34
+ },
35
+ "1042": {
36
+ cause: 'التدفق "{flow}" يُصدِر الإشارة "{resource}" بلا مشترك.',
37
+ fix: "أضف on({resource}, …) أو عيّن الإشارة '{'optional: true'}'.",
38
+ },
39
+ "1101": {
40
+ cause: "جدول النطاق غير موجود — لم تُطبَّق الترحيلات.",
41
+ fix: "شغّل `oke db migrate` على هذه البيئة.",
42
+ },
43
+ },
44
+ errors: {
45
+ Unauthorized: "المصادقة مطلوبة.",
46
+ Forbidden: "غير مسموح لك بتنفيذ هذا الإجراء.",
47
+ RateLimited: "طلبات كثيرة جداً. حاول لاحقاً.",
48
+ ValidationError: "فشل التحقق من الطلب.",
49
+ NotFound: "المورد المطلوب غير موجود.",
50
+ AuthFailed: "فشلت المصادقة.",
51
+ AuthRateLimited: "محاولات مصادقة كثيرة جداً. حاول لاحقاً.",
52
+ "AuthFailed.invalid_credentials": "بيانات الاعتماد غير صحيحة.",
53
+ "AuthFailed.invalid_refresh": "رمز التحديث غير صالح أو منتهٍ.",
54
+ "AuthFailed.invalid_email": "أدخل بريداً إلكترونياً صالحاً.",
55
+ "AuthFailed.invalid_phone": "أدخل رقم هاتف صالحاً.",
56
+ "AuthFailed.unauthenticated": "سجّل الدخول للمتابعة.",
57
+ "AuthFailed.password_breached": "اختر كلمة مرور أخرى — هذه تظهر في قائمة اختراق.",
58
+ "AuthFailed.username_policy": "اسم المستخدم لا يستوفي متطلبات السياسة.",
59
+ "AuthFailed.password_policy": "كلمة المرور لا تستوفي متطلبات السياسة.",
60
+ "AuthFailed.invalid_origin": "تم رفض أصل مفتاح المرور.",
61
+ "Forbidden.csrf": "تم حظر الطلب عبر المواقع.",
62
+ "Forbidden.ip_denied": "عنوان IP الخاص بك محظور.",
63
+ "Forbidden.ip_not_allowed": "عنوان IP الخاص بك غير مسموح.",
64
+ "Forbidden.policy_denied": "رفضت السياسة هذا الطلب.",
65
+ "AuthRateLimited.rate_limited": "محاولات مصادقة كثيرة جداً. حاول لاحقاً.",
66
+ },
67
+ } as const;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Built-in English messages — framework OKE codes + typed failure messages.
3
+ */
4
+
5
+ export const builtinEn = {
6
+ oke: {
7
+ "1001": {
8
+ cause: 'Flow "{flow}" reads "{resource}" without declaring it.',
9
+ fix: "Add \"{resource}\" to this flow''s effects.reads.",
10
+ },
11
+ "1002": {
12
+ cause: 'Flow "{flow}" writes "{resource}" without declaring it.',
13
+ fix: "Add \"{resource}\" to this flow''s effects.writes.",
14
+ },
15
+ "1003": {
16
+ cause: 'Flow "{flow}" emits "{resource}" without declaring it.',
17
+ fix: "Add \"{resource}\" to this flow''s effects.emits.",
18
+ },
19
+ "1004": {
20
+ cause: 'Flow "{flow}" sends "{resource}" without declaring it.',
21
+ fix: "Add \"{resource}\" to this flow''s effects.sends.",
22
+ },
23
+ "1005": {
24
+ cause: 'Flow "{flow}" asks "{resource}" without declaring it.',
25
+ fix: "Add \"{resource}\" to this flow''s effects.asks.",
26
+ },
27
+ "1006": {
28
+ cause: 'Flow "{flow}" reads secret "{resource}" without declaring it.',
29
+ fix: "Add \"{resource}\" to this flow''s effects.secrets.",
30
+ },
31
+ "1007": {
32
+ cause: 'Flow "{flow}" calls "{resource}" without declaring it.',
33
+ fix: "Add \"{resource}\" to this flow''s effects.calls.",
34
+ },
35
+ "1042": {
36
+ cause: 'Flow "{flow}" emits signal "{resource}" with no subscriber.',
37
+ fix: "Add on({resource}, …) or mark the signal '{'optional: true'}'.",
38
+ },
39
+ "1101": {
40
+ cause: "domain table not found — migrations have not been applied.",
41
+ fix: "run `oke db migrate` against this environment.",
42
+ },
43
+ },
44
+ errors: {
45
+ Unauthorized: "Authentication required.",
46
+ Forbidden: "You are not allowed to perform this action.",
47
+ RateLimited: "Too many requests. Try again later.",
48
+ ValidationError: "The request failed validation.",
49
+ NotFound: "The requested resource was not found.",
50
+ AuthFailed: "Authentication failed.",
51
+ AuthRateLimited: "Too many authentication attempts. Try again later.",
52
+ "AuthFailed.invalid_credentials": "Invalid credentials.",
53
+ "AuthFailed.invalid_refresh": "Refresh token is invalid or expired.",
54
+ "AuthFailed.invalid_email": "Enter a valid email address.",
55
+ "AuthFailed.invalid_phone": "Enter a valid phone number.",
56
+ "AuthFailed.unauthenticated": "Sign in to continue.",
57
+ "AuthFailed.password_breached":
58
+ "Choose a different password — this one appears in a breach list.",
59
+ "AuthFailed.username_policy": "That username does not meet the policy requirements.",
60
+ "AuthFailed.password_policy": "That password does not meet the policy requirements.",
61
+ "AuthFailed.invalid_origin": "Passkey origin was rejected.",
62
+ "Forbidden.csrf": "Cross-site request blocked.",
63
+ "Forbidden.ip_denied": "Your IP address is blocked.",
64
+ "Forbidden.ip_not_allowed": "Your IP address is not allowed.",
65
+ "Forbidden.policy_denied": "Policy denied this request.",
66
+ "AuthRateLimited.rate_limited": "Too many authentication attempts. Try again later.",
67
+ },
68
+ } as const;
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fail } from "../kernel/errors.ts";
3
+ import { OKE_ERRORS, OkeError } from "../kernel/errors.ts";
4
+ import { catalogReasonKey, resolveFailureMessage } from "./failure-message.ts";
5
+ import { runWithLocale } from "./locale-context.ts";
6
+
7
+ describe("catalogReasonKey", () => {
8
+ test("keeps identifiers; slugifies free text", () => {
9
+ expect(catalogReasonKey("invalid_credentials")).toBe("invalid_credentials");
10
+ expect(catalogReasonKey("policy denied")).toBe("policy_denied");
11
+ });
12
+ });
13
+
14
+ describe("resolveFailureMessage", () => {
15
+ test("resolves reason-specific then code-level keys", () => {
16
+ expect(resolveFailureMessage("AuthFailed", { reason: "invalid_credentials" }, "en")).toBe(
17
+ "Invalid credentials.",
18
+ );
19
+ expect(resolveFailureMessage("AuthFailed", { reason: "invalid_credentials" }, "ar")).toBe(
20
+ "بيانات الاعتماد غير صحيحة.",
21
+ );
22
+ expect(resolveFailureMessage("Unauthorized", {}, "ar")).toBe("المصادقة مطلوبة.");
23
+ expect(resolveFailureMessage("FlightFull", { seats: 0 }, "en")).toBeUndefined();
24
+ });
25
+ });
26
+
27
+ describe("fail — auto message", () => {
28
+ test("attaches localized message from active locale", () => {
29
+ const en = runWithLocale({ locale: "en", defaultLocale: "en" }, () =>
30
+ fail("AuthFailed", { reason: "invalid_email" }),
31
+ );
32
+ expect(en.error.message).toBe("Enter a valid email address.");
33
+
34
+ const ar = runWithLocale({ locale: "ar", defaultLocale: "en" }, () => fail("NotFound", {}));
35
+ expect(ar.error.message).toBe("المورد المطلوب غير موجود.");
36
+ });
37
+
38
+ test("explicit message wins", () => {
39
+ const r = fail("AuthFailed", { reason: "invalid_email" }, { message: "custom" });
40
+ expect(r.error.message).toBe("custom");
41
+ });
42
+ });
43
+
44
+ describe("OkeError — localized cause/fix", () => {
45
+ test("uses Arabic catalog when locale is ar", () => {
46
+ const err = new OkeError(
47
+ OKE_ERRORS.UNDECLARED_READ,
48
+ { flow: "notes.create", resource: "sql:notes" },
49
+ "ar",
50
+ );
51
+ expect(err.causeText).toContain("notes.create");
52
+ expect(err.causeText).toContain("sql:notes");
53
+ expect(err.causeText).toContain("يقرأ");
54
+ expect(err.fix).toContain("effects.reads");
55
+ });
56
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Resolve a localized `error.message` for typed flow failures.
3
+ */
4
+
5
+ import { getActiveDefaultLocale, getActiveLocale } from "./locale-context.ts";
6
+ import { getMessageCatalogs, translate } from "./messages.ts";
7
+ import type { MessageValues } from "./types.ts";
8
+
9
+ /**
10
+ * Whether a reason string is safe as a catalog key segment.
11
+ *
12
+ * @param reason - Failure `data.reason`
13
+ */
14
+ export function isCatalogReason(reason: string): boolean {
15
+ return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(reason);
16
+ }
17
+
18
+ /**
19
+ * Normalize a free-text reason into a catalog key segment.
20
+ *
21
+ * @param reason - Raw reason
22
+ */
23
+ export function catalogReasonKey(reason: string): string {
24
+ if (isCatalogReason(reason)) return reason;
25
+ return reason
26
+ .trim()
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, "_")
29
+ .replace(/^_|_$/g, "");
30
+ }
31
+
32
+ /**
33
+ * Build ICU values from failure data (string/number/boolean leaves only).
34
+ *
35
+ * @param data - Failure payload
36
+ */
37
+ export function failureMessageValues(data: unknown): MessageValues | undefined {
38
+ if (data === null || data === undefined || typeof data !== "object") return undefined;
39
+ const out: Record<string, string | number | boolean> = {};
40
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
41
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
42
+ out[key] = value;
43
+ }
44
+ }
45
+ return Object.keys(out).length > 0 ? out : undefined;
46
+ }
47
+
48
+ /**
49
+ * Resolve a localized message for a typed failure, or `undefined` when no
50
+ * built-in / app catalog entry exists (custom app codes stay message-less).
51
+ *
52
+ * Lookup order: `errors.{code}.{reason}` → `errors.{code}`.
53
+ *
54
+ * @param code - Failure code (`AuthFailed`, `Unauthorized`, …)
55
+ * @param data - Failure data (may include `reason`)
56
+ * @param locale - Override locale (defaults to active request locale)
57
+ */
58
+ export function resolveFailureMessage(
59
+ code: string,
60
+ data?: unknown,
61
+ locale?: string,
62
+ ): string | undefined {
63
+ const catalogs = getMessageCatalogs();
64
+ const activeLocale = locale ?? getActiveLocale("en");
65
+ const defaultLocale = getActiveDefaultLocale("en");
66
+ const values = failureMessageValues(data);
67
+ const reason =
68
+ data !== null &&
69
+ data !== undefined &&
70
+ typeof data === "object" &&
71
+ "reason" in data &&
72
+ typeof (data as { reason: unknown }).reason === "string"
73
+ ? catalogReasonKey((data as { reason: string }).reason)
74
+ : undefined;
75
+
76
+ const keys: string[] = [];
77
+ if (reason) keys.push(`errors.${code}.${reason}`);
78
+ keys.push(`errors.${code}`);
79
+
80
+ for (const key of keys) {
81
+ const primary = catalogs[activeLocale]?.[key];
82
+ const fallback = catalogs[defaultLocale]?.[key];
83
+ if (primary === undefined && fallback === undefined) continue;
84
+ return translate({
85
+ locale: activeLocale,
86
+ defaultLocale,
87
+ catalogs,
88
+ key,
89
+ values,
90
+ });
91
+ }
92
+ return undefined;
93
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * ICU MessageFormat formatting via FormatJS (`intl-messageformat`).
3
+ *
4
+ * Supports interpolation, cardinal/ordinal plurals, `select` / `selectordinal`,
5
+ * and rich-text tags (`<tag>…</tag>` with function values).
6
+ */
7
+
8
+ import IntlMessageFormat from "intl-messageformat";
9
+ import type { MessageValues } from "./types.ts";
10
+
11
+ const cache = new Map<string, IntlMessageFormat>();
12
+
13
+ /**
14
+ * Clear the compiled-message cache (tests only).
15
+ */
16
+ export function clearMessageFormatCache(): void {
17
+ cache.clear();
18
+ }
19
+
20
+ /**
21
+ * Format an ICU message for a locale.
22
+ *
23
+ * @param message - ICU MessageFormat source
24
+ * @param locale - BCP 47 locale tag
25
+ * @param values - Interpolation / plural / select / rich-text values
26
+ */
27
+ export function formatMessage(message: string, locale: string, values?: MessageValues): string {
28
+ const cacheKey = `${locale}\0${message}`;
29
+ let formatter = cache.get(cacheKey);
30
+ if (!formatter) {
31
+ try {
32
+ formatter = new IntlMessageFormat(message, locale);
33
+ } catch {
34
+ // Malformed ICU — treat as a plain template.
35
+ return message;
36
+ }
37
+ cache.set(cacheKey, formatter);
38
+ }
39
+
40
+ try {
41
+ const result = formatter.format(values as Record<string, unknown> | undefined);
42
+ return partsToString(result);
43
+ } catch {
44
+ // Missing args — leave the source for callers that fall back.
45
+ return message;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Collapse FormatJS rich-text / string output to a single string.
51
+ *
52
+ * @param result - `format()` return value
53
+ */
54
+ function partsToString(result: unknown): string {
55
+ if (typeof result === "string") return result;
56
+ if (typeof result === "number" || typeof result === "boolean") return String(result);
57
+ if (Array.isArray(result)) {
58
+ return result
59
+ .map((part) => {
60
+ if (typeof part === "string" || typeof part === "number") return String(part);
61
+ return "";
62
+ })
63
+ .join("");
64
+ }
65
+ if (result == null) return "";
66
+ return String(result);
67
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * App i18n — ICU message catalogs for `fx.t`, typed keys, locale helpers.
3
+ *
4
+ * Channel locale resolution lives in `elements/channel/locale.ts`; this
5
+ * module owns Flow-facing string catalogs registered via {@link defineLocale}.
6
+ */
7
+
8
+ export { clearMessageFormatCache, formatMessage } from "./format.ts";
9
+
10
+ export {
11
+ catalogReasonKey,
12
+ failureMessageValues,
13
+ isCatalogReason,
14
+ resolveFailureMessage,
15
+ } from "./failure-message.ts";
16
+
17
+ export {
18
+ getActiveDefaultLocale,
19
+ getActiveLocale,
20
+ getLocaleContext,
21
+ runWithLocale,
22
+ type LocaleContext,
23
+ } from "./locale-context.ts";
24
+
25
+ export {
26
+ clearMessageCatalogs,
27
+ defineLocale,
28
+ defineMessages,
29
+ flattenMessages,
30
+ getMessageCatalogs,
31
+ interpolateMessage,
32
+ matchConfiguredLocale,
33
+ translate,
34
+ type MessageCatalog,
35
+ type MessageCatalogs,
36
+ type TranslateOptions,
37
+ } from "./messages.ts";
38
+
39
+ export type {
40
+ AppMessageKey,
41
+ FlattenKeys,
42
+ MessageTree,
43
+ MessageValue,
44
+ MessageValues,
45
+ MessagesFor,
46
+ Register,
47
+ } from "./types.ts";
48
+
49
+ export {
50
+ formatLocaleChain,
51
+ isRtlLocale,
52
+ parseAcceptLanguage,
53
+ resolveLocale,
54
+ type LocaleChainStep,
55
+ type LocaleResolution,
56
+ type ResolveLocaleOptions,
57
+ } from "../elements/channel/locale.ts";
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Request-scoped locale for {@link fail} / {@link OkeError} localization.
3
+ */
4
+
5
+ import { AsyncLocalStorage } from "node:async_hooks";
6
+
7
+ /** Active locale bag for the current async context. */
8
+ export interface LocaleContext {
9
+ readonly locale: string;
10
+ readonly defaultLocale: string;
11
+ }
12
+
13
+ const storage = new AsyncLocalStorage<LocaleContext>();
14
+
15
+ /**
16
+ * Run `fn` with an active locale context (request / invocation scope).
17
+ *
18
+ * @param ctx - Locale + default
19
+ * @param fn - Work to run
20
+ */
21
+ export function runWithLocale<T>(ctx: LocaleContext, fn: () => T): T {
22
+ return storage.run(ctx, fn);
23
+ }
24
+
25
+ /**
26
+ * Active locale context, if any.
27
+ */
28
+ export function getLocaleContext(): LocaleContext | undefined {
29
+ return storage.getStore();
30
+ }
31
+
32
+ /**
33
+ * Active locale, or `fallback` when none is set.
34
+ *
35
+ * @param fallback - Default when outside a request (default `"en"`)
36
+ */
37
+ export function getActiveLocale(fallback = "en"): string {
38
+ return storage.getStore()?.locale ?? fallback;
39
+ }
40
+
41
+ /**
42
+ * Active default locale for catalog fallback.
43
+ *
44
+ * @param fallback - Default when outside a request (default `"en"`)
45
+ */
46
+ export function getActiveDefaultLocale(fallback = "en"): string {
47
+ return storage.getStore()?.defaultLocale ?? fallback;
48
+ }