From f704f71c755ec3b39f24ada16af8473350b09e1b Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sun, 16 Nov 2025 22:11:36 +0900 Subject: [PATCH 1/4] feat(hono): support async locale detector --- packages/h3/package.json | 2 +- packages/h3/spec/integration.spec.ts | 7 +- packages/hono/README.md | 60 ++++++- packages/hono/package.json | 2 +- packages/hono/playground/basic/index.ts | 4 +- .../hono/playground/global-schema/index.ts | 4 +- .../hono/playground/local-schema/index.ts | 4 +- packages/hono/spec/fixtures/en.json | 3 + packages/hono/spec/fixtures/ja.json | 3 + packages/hono/spec/integration.spec.ts | 168 +++++++++++++++--- packages/hono/src/index.test-d.ts | 2 +- packages/hono/src/index.test.ts | 21 ++- packages/hono/src/index.ts | 58 ++++-- pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 1 + 15 files changed, 277 insertions(+), 69 deletions(-) create mode 100644 packages/hono/spec/fixtures/en.json create mode 100644 packages/hono/spec/fixtures/ja.json diff --git a/packages/h3/package.json b/packages/h3/package.json index 0a1c793..2954168 100644 --- a/packages/h3/package.json +++ b/packages/h3/package.json @@ -63,7 +63,7 @@ "prepack": "pnpm build" }, "dependencies": { - "@intlify/core": "^11.1.12", + "@intlify/core": "catalog:", "@intlify/utils": "catalog:" }, "devDependencies": { diff --git a/packages/h3/spec/integration.spec.ts b/packages/h3/spec/integration.spec.ts index 0b7c214..2c06eee 100644 --- a/packages/h3/spec/integration.spec.ts +++ b/packages/h3/spec/integration.spec.ts @@ -53,7 +53,7 @@ test('translation', async () => { }) describe('custom locale detection', () => { - test('basic', async () => { + test('basic detection', async () => { // define custom locale detector const localeDetector = (event: H3Event): string => { return getQueryLocale(event.req).toString() @@ -88,7 +88,7 @@ describe('custom locale detection', () => { expect(body).toEqual({ message: 'こんにちは, h3' }) }) - test('async', async () => { + test('detect with async loading', async () => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const loader = (path: string) => import(path).then(m => m.default || m) @@ -150,7 +150,8 @@ describe('custom locale detection', () => { expect(body).toEqual(translated[locale]) } }) - test('async parallel', async () => { + + test('detect with async parallel loading', async () => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const loader = (path: string) => import(path).then(m => m.default || m) diff --git a/packages/hono/README.md b/packages/hono/README.md index a1383a7..3c0973b 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -101,9 +101,9 @@ const app = new Hono() // install middleware with `app.use` app.use('*', i18nMiddleware) -app.get('/', c => { +app.get('/', async c => { // use `useTranslation` in handler - const t = useTranslation(c) + const t = await useTranslation(c) return c.text(t('hello', { name: 'hono' }) + `\n`) }) @@ -139,6 +139,54 @@ const middleware = defineI18nMiddleware({ }) ``` +ou can make that function asynchronous. This is useful when loading resources along with locale detection. + + + +> [!NOTE] +> The case which a synchronous function returns a promise is not supported. you need to use `async function`. + + + +```ts +import { Hono } from 'hono' +import { defineI18nMiddleware, getCookieLocale } from '@intlify/hono' + +import type { Context } from 'hono' +import type { DefineLocaleMessage, CoreContext } from '@intlify/h3' + +const loader = (path: string) => import(path).then(m => m.default) +const messages: Record ReturnType> = { + en: () => loader('./locales/en.json'), + ja: () => loader('./locales/ja.json') +} + +// define custom locale detector and lazy loading +const localeDetector = async ( + ctx: Context, + i18n: CoreContext +): Promise => { + // detect locale + const locale = getCookieLocale(ctx.req.raw).toString() + + // resource lazy loading + const loader = messages[locale] + if (loader && !i18n.messages[locale]) { + const message = await loader() + i18n.messages[locale] = message + } + + return locale +} + +const middleware = defineI18nMiddleware({ + // set your custom locale detector + locale: localeDetector + // something options + // ... +}) +``` + ## 🧩 Type-safe resources @@ -234,12 +282,12 @@ You can `useTranslation` set the type parameter to the resource schema you want the part of example: ```ts -app.get('/', c => { +app.get('/', async c => { type ResourceSchema = { hello: string } // set resource schema as type parameter - const t = useTranslation(c) + const t = await useTranslation(c) // you can completion when you type `t('` return c.json(t('hello', { name: 'hono' })) }) @@ -270,8 +318,8 @@ declare module '@intlify/hono' { export interface DefineLocaleMessage extends ResourceSchema {} } -app.get('/', c => { - const t = useTranslation(c) +app.get('/', async c => { + const t = await useTranslation(c) // you can completion when you type `t('` return c.json(t('hello', { name: 'hono' })) }) diff --git a/packages/hono/package.json b/packages/hono/package.json index 971ca8c..8e414a6 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -62,7 +62,7 @@ "prepack": "pnpm build" }, "dependencies": { - "@intlify/core": "^11.0.0", + "@intlify/core": "catalog:", "@intlify/utils": "catalog:" }, "devDependencies": { diff --git a/packages/hono/playground/basic/index.ts b/packages/hono/playground/basic/index.ts index 96f5cd7..cc4bc21 100644 --- a/packages/hono/playground/basic/index.ts +++ b/packages/hono/playground/basic/index.ts @@ -20,8 +20,8 @@ const i18n = defineI18nMiddleware({ const app: Hono = new Hono() app.use('*', i18n) -app.get('/', c => { - const t = useTranslation(c) +app.get('/', async c => { + const t = await useTranslation(c) return c.text(t('hello', { name: 'hono' }) + `\n`) }) diff --git a/packages/hono/playground/global-schema/index.ts b/packages/hono/playground/global-schema/index.ts index d8b6bc2..4ba491e 100644 --- a/packages/hono/playground/global-schema/index.ts +++ b/packages/hono/playground/global-schema/index.ts @@ -27,8 +27,8 @@ const i18n = defineI18nMiddleware({ const app: Hono = new Hono() app.use('*', i18n) -app.get('/', c => { - const t = useTranslation(c) +app.get('/', async c => { + const t = await useTranslation(c) return c.text(t('hello', { name: 'hono' })) }) diff --git a/packages/hono/playground/local-schema/index.ts b/packages/hono/playground/local-schema/index.ts index bb8f412..4395b5d 100644 --- a/packages/hono/playground/local-schema/index.ts +++ b/packages/hono/playground/local-schema/index.ts @@ -18,11 +18,11 @@ const i18n = defineI18nMiddleware({ const app: Hono = new Hono() app.use('*', i18n) -app.get('/', c => { +app.get('/', async c => { type ResourceSchema = { hello: string } - const t = useTranslation(c) + const t = await useTranslation(c) return c.text(t('hello', { name: 'hono' })) }) diff --git a/packages/hono/spec/fixtures/en.json b/packages/hono/spec/fixtures/en.json new file mode 100644 index 0000000..f47d43a --- /dev/null +++ b/packages/hono/spec/fixtures/en.json @@ -0,0 +1,3 @@ +{ + "hello": "hello, {name}" +} diff --git a/packages/hono/spec/fixtures/ja.json b/packages/hono/spec/fixtures/ja.json new file mode 100644 index 0000000..907ec40 --- /dev/null +++ b/packages/hono/spec/fixtures/ja.json @@ -0,0 +1,3 @@ +{ + "hello": "こんにちは, {name}" +} diff --git a/packages/hono/spec/integration.spec.ts b/packages/hono/spec/integration.spec.ts index 4171d3d..296926e 100644 --- a/packages/hono/spec/integration.spec.ts +++ b/packages/hono/spec/integration.spec.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import { afterEach, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, test, vi } from 'vitest' import { defineI18nMiddleware, detectLocaleFromAcceptLanguageHeader, @@ -7,7 +7,9 @@ import { useTranslation } from '../src/index.ts' +import type { CoreContext } from '@intlify/core' import type { Context } from 'hono' +import type { DefineLocaleMessage } from '../src/index.ts' let app: Hono @@ -29,8 +31,8 @@ test('translation', async () => { }) app = new Hono() app.use('*', i18nMiddleware) - app.get('/', c => { - const t = useTranslation(c) + app.get('/', async c => { + const t = await useTranslation(c) return c.json({ message: t('hello', { name: 'hono' }) }) }) @@ -42,36 +44,152 @@ test('translation', async () => { expect(await res.json()).toEqual({ message: 'hello, hono' }) }) -test('custom locale detection', async () => { - const defaultLocale = 'en' +describe('custom locale detection', () => { + test('basic detection', async () => { + const defaultLocale = 'en' - // define custom locale detector - const localeDetector = (ctx: Context): string => { - try { - return getQueryLocale(ctx.req.raw).toString() - } catch { - return defaultLocale + // define custom locale detector + const localeDetector = (ctx: Context): string => { + try { + return getQueryLocale(ctx.req.raw).toString() + } catch { + return defaultLocale + } } - } - const i18nMiddleware = defineI18nMiddleware({ - locale: localeDetector, - messages: { + const i18nMiddleware = defineI18nMiddleware({ + locale: localeDetector, + messages: { + en: { + hello: 'hello, {name}' + }, + ja: { + hello: 'こんにちは, {name}' + } + } + }) + app = new Hono() + app.use('*', i18nMiddleware) + app.get('/', async c => { + const t = await useTranslation(c) + return c.json({ message: t('hello', { name: 'hono' }) }) + }) + + const res = await app.request('/?locale=ja') + expect(await res.json()).toEqual({ message: 'こんにちは, hono' }) + }) + + test('detect with async loading', async () => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + + const loader = (path: string) => import(path).then(m => m.default || m) + const messages: Record ReturnType> = { + en: () => loader('./fixtures/en.json'), + ja: () => loader('./fixtures/ja.json') + } + + // async locale detector + const localeDetector = async (ctx: Context, i18n: CoreContext) => { + const locale = getQueryLocale(ctx.req.raw).toString() + await sleep(100) + const loader = messages[locale] + if (loader && !i18n.messages[locale]) { + const message = await loader() + i18n.messages[locale] = message + } + return locale + } + + const i18nMiddleware = defineI18nMiddleware({ + locale: localeDetector, + messages: { + en: { + hello: 'hello, {name}' + }, + ja: { + hello: 'こんにちは, {name}' + } + } + }) + + app = new Hono() + app.use('*', i18nMiddleware) + app.get('/', async c => { + const t = await useTranslation(c) + return c.json({ message: t('hello', { name: 'h3' }) }) + }) + + const translated: Record = { en: { - hello: 'hello, {name}' + message: 'hello, h3' }, ja: { - hello: 'こんにちは, {name}' + message: 'こんにちは, h3' } } + + for (const locale of ['en', 'ja']) { + const res = await app.request(`/?locale=${locale}`) + expect(await res.json()).toEqual(translated[locale]) + } }) - app = new Hono() - app.use('*', i18nMiddleware) - app.get('/', c => { - const t = useTranslation(c) - return c.json({ message: t('hello', { name: 'hono' }) }) - }) - const res = await app.request('/?locale=ja') - expect(await res.json()).toEqual({ message: 'こんにちは, hono' }) + test('detect with async parallel loading', async () => { + const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + + const loader = (path: string) => import(path).then(m => m.default || m) + const messages: Record ReturnType> = { + en: () => loader('./fixtures/en.json'), + ja: () => loader('./fixtures/ja.json') + } + + // async locale detector + const localeDetector = async (ctx: Context, i18n: CoreContext) => { + const locale = getQueryLocale(ctx.req.raw).toString() + await sleep(100) + const loader = messages[locale] + if (loader && !i18n.messages[locale]) { + const message = await loader() + i18n.messages[locale] = message + } + return locale + } + + const i18nMiddleware = defineI18nMiddleware({ + locale: localeDetector, + messages: { + en: { + hello: 'hello, {name}' + } + } + }) + + app = new Hono() + app.use('*', i18nMiddleware) + app.use('/', async c => { + await sleep(100) + const t = await useTranslation(c) + await sleep(100) + return c.json({ message: t('hello', { name: 'h3' }) }) + }) + + const translated: Record = { + en: { + message: 'hello, h3' + }, + ja: { + message: 'こんにちは, h3' + } + } + // request in parallel + const resList = await Promise.all( + ['en', 'ja'].map(locale => + app + .request(`/?locale=${locale}`) + // @ts-ignore + .then(res => res.json()) + ) + ) + expect(resList).toEqual([translated['en'], translated['ja']]) + }) }) diff --git a/packages/hono/src/index.test-d.ts b/packages/hono/src/index.test-d.ts index e972603..1772780 100644 --- a/packages/hono/src/index.test-d.ts +++ b/packages/hono/src/index.test-d.ts @@ -16,5 +16,5 @@ test('defineI18nMiddleware', () => { } }) - expectTypeOf(middleware).toMatchTypeOf() + expectTypeOf(middleware).toExtend() }) diff --git a/packages/hono/src/index.test.ts b/packages/hono/src/index.test.ts index aca8e57..bb72c59 100644 --- a/packages/hono/src/index.test.ts +++ b/packages/hono/src/index.test.ts @@ -40,7 +40,7 @@ test('defineI18nMiddleware', () => { }) describe('useTranslation', () => { - test('basic', () => { + test('basic', async () => { /** * setup `defineI18nMiddleware` emulates */ @@ -63,19 +63,22 @@ describe('useTranslation', () => { } } }, - get: (_key: string) => context + get: (key: string) => { + if (key === 'i18n') { + return context + } else if (key === 'i18nLocaleDetector') { + const locale = context.locale as unknown + return (locale as LocaleDetector).bind(null, mockContext) + } + } } as Context - const locale = context.locale as unknown - const bindLocaleDetector = (locale as LocaleDetector).bind(null, mockContext) - // @ts-ignore ignore type error because this is test - context.locale = bindLocaleDetector // test `useTranslation` - const t = useTranslation(mockContext) + const t = await useTranslation(mockContext) expect(t('hello', { name: 'hono' })).toEqual('こんにちは, hono') }) - test('not initialize context', () => { + test('not initialize context', async () => { const mockContext = { req: { raw: { @@ -87,6 +90,6 @@ describe('useTranslation', () => { get: (_key: string) => {} } as Context - expect(() => useTranslation(mockContext)).toThrowError() + await expect(() => useTranslation(mockContext)).rejects.toThrowError() }) }) diff --git a/packages/hono/src/index.ts b/packages/hono/src/index.ts index 65f8cac..471bd96 100644 --- a/packages/hono/src/index.ts +++ b/packages/hono/src/index.ts @@ -9,7 +9,12 @@ * @license MIT */ -import { translate as _translate, createCoreContext, NOT_REOSLVED } from '@intlify/core' +import { + translate as _translate, + createCoreContext, + NOT_REOSLVED, // @ts-expect-error -- NOTE(kazupon): internal function + parseTranslateArgs +} from '@intlify/core' import { getHeaderLocale } from '@intlify/utils' export { @@ -50,6 +55,7 @@ import type { Context, MiddlewareHandler, Next } from 'hono' declare module 'hono' { interface ContextVariableMap { i18n?: CoreContext + i18nLocaleDetector?: LocaleDetector } } @@ -108,7 +114,7 @@ export interface DefineLocaleMessage extends LocaleMessage {} * }, * }, * // your locale detection logic here - * locale: (event) => { + * locale: (c) => { * // ... * }, * }) @@ -138,23 +144,25 @@ export function defineI18nMiddleware< staticLocaleDetector = () => orgLocale } - const getLocaleDetector = (ctx: Context): LocaleDetector => { - // deno-fmt-ignore + const getLocaleDetector = (ctx: Context, i18n: CoreContext): LocaleDetector => { return typeof orgLocale === 'function' - ? orgLocale.bind(null, ctx) + ? orgLocale.bind(null, ctx, i18n) : staticLocaleDetector == null ? detectLocaleFromAcceptLanguageHeader.bind(null, ctx) - : staticLocaleDetector.bind(null, ctx) + : staticLocaleDetector.bind(null, ctx, i18n) } return async (ctx: Context, next: Next) => { - i18n.locale = getLocaleDetector(ctx) + const detector = getLocaleDetector(ctx, i18n as CoreContext) + i18n.locale = detector + ctx.set('i18nLocaleDetector', detector) ctx.set('i18n', i18n as CoreContext) await next() i18n.locale = orgLocale ctx.set('i18n', undefined) + ctx.set('i18nLocaleDetector', undefined) } } @@ -307,9 +315,6 @@ interface TranslationFunction< /** * use translation function in event handler * - * @description - * This function must be initialized with defineI18nMiddleware. See about the {@link defineI18nMiddleware} - * * @param ctx - A Hono context * @returns Return a translation function, which can be translated with i18n resource messages * @@ -333,16 +338,16 @@ interface TranslationFunction< * app.use('*', i18nMiddleware) * // setup other middlewares ... * - * app.get('/', (ctx) => { - * const t = useTranslation(ctx) + * app.get('/', async (ctx) => { + * const t = await useTranslation(ctx) * return ctx.text(t('hello', { name: 'hono' })) * }) * ``` */ -export function useTranslation< +export async function useTranslation< Schema extends Record = {}, // eslint-disable-line @typescript-eslint/no-explicit-any -- NOTE(kazupon): generic type HonoContext extends Context = Context ->(ctx: HonoContext): TranslationFunction { +>(ctx: HonoContext): Promise> { const i18n = ctx.get('i18n') if (i18n == null) { throw new Error( @@ -350,8 +355,31 @@ export function useTranslation< ) } + const localeDetector = ctx.get('i18nLocaleDetector') + if (localeDetector == null) { + throw new Error( + 'locale detector not found in context, please make sure that the i18n middleware is correctly set up' + ) + } + // Always await detector call - works for both sync and async detectors + // (awaiting a non-promise value returns it immediately) + const locale = await localeDetector(ctx) + i18n.locale = locale + function translate(key: string, ...args: unknown[]): string { - const result = Reflect.apply(_translate, null, [i18n!, key, ...args]) + const [_, options] = parseTranslateArgs(key, ...args) + const [arg2] = args + + const result = Reflect.apply(_translate, null, [ + i18n, + key, + arg2, + { + // bind to request locale + locale, + ...options + } + ]) return NOT_REOSLVED === result ? key : (result as string) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29554b8..5951238 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@intlify/core': + specifier: ^11.1.12 + version: 11.1.12 '@intlify/utils': specifier: ^1.0.1 version: 1.0.1 @@ -123,7 +126,7 @@ importers: packages/h3: dependencies: '@intlify/core': - specifier: ^11.1.12 + specifier: 'catalog:' version: 11.1.12 '@intlify/utils': specifier: 'catalog:' @@ -157,7 +160,7 @@ importers: packages/hono: dependencies: '@intlify/core': - specifier: ^11.0.0 + specifier: 'catalog:' version: 11.1.12 '@intlify/utils': specifier: 'catalog:' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 51ecb75..51def3f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: catalog: '@intlify/utils': ^1.0.1 + '@intlify/core': ^11.1.12 '@types/node': ^24.10.0 publint: ^0.3.15 tsdown: ^0.16.4 From 386ad827a13b261b2096023e35aca4a5137b70dd Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sun, 16 Nov 2025 22:16:41 +0900 Subject: [PATCH 2/4] fix: test codes --- packages/hono/spec/integration.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/hono/spec/integration.spec.ts b/packages/hono/spec/integration.spec.ts index 296926e..2599d62 100644 --- a/packages/hono/spec/integration.spec.ts +++ b/packages/hono/spec/integration.spec.ts @@ -116,15 +116,15 @@ describe('custom locale detection', () => { app.use('*', i18nMiddleware) app.get('/', async c => { const t = await useTranslation(c) - return c.json({ message: t('hello', { name: 'h3' }) }) + return c.json({ message: t('hello', { name: 'hono' }) }) }) const translated: Record = { en: { - message: 'hello, h3' + message: 'hello, hono' }, ja: { - message: 'こんにちは, h3' + message: 'こんにちは, hono' } } @@ -170,15 +170,15 @@ describe('custom locale detection', () => { await sleep(100) const t = await useTranslation(c) await sleep(100) - return c.json({ message: t('hello', { name: 'h3' }) }) + return c.json({ message: t('hello', { name: 'hono' }) }) }) const translated: Record = { en: { - message: 'hello, h3' + message: 'hello, hono' }, ja: { - message: 'こんにちは, h3' + message: 'こんにちは, hono' } } // request in parallel From 1b3da8b311cc6becdf3796ba1fb11da74770c2a8 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sun, 16 Nov 2025 22:22:36 +0900 Subject: [PATCH 3/4] fix: e2e test --- packages/hono/spec/e2e.spec.ts | 2 +- vitest.e2e.config.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/hono/spec/e2e.spec.ts b/packages/hono/spec/e2e.spec.ts index 8b2658c..79e19d3 100644 --- a/packages/hono/spec/e2e.spec.ts +++ b/packages/hono/spec/e2e.spec.ts @@ -54,6 +54,6 @@ describe('e2e', () => { const stdout = await runCommand( `curl -H 'Accept-Language: ja,en-US;q=0.7,en;q=0.3' http://localhost:3000` ) - expect(stdout).toContain(`こんにちは, h3`) + expect(stdout).toContain(`こんにちは, hono`) }) }) diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index c913e29..fd3cb2c 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ globals: true, testTimeout: 60_000, include: ['**/*.spec.?(c|m)[jt]s?(x)'], - exclude: [...defaultExclude] + exclude: [...defaultExclude], + // Run e2e tests serially (not in parallel) + fileParallelism: false } }) From b2f278dbbbc145ede2685d95d4d8f59ec69f0c32 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sun, 16 Nov 2025 22:23:27 +0900 Subject: [PATCH 4/4] docs: fix --- packages/hono/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/hono/README.md b/packages/hono/README.md index 3c0973b..6aed520 100644 --- a/packages/hono/README.md +++ b/packages/hono/README.md @@ -139,7 +139,7 @@ const middleware = defineI18nMiddleware({ }) ``` -ou can make that function asynchronous. This is useful when loading resources along with locale detection. +You can make that function asynchronous. This is useful when loading resources along with locale detection.