diff --git a/bun.lockb b/bun.lockb index 9c7d85d..0cf56fd 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index d42c720..edcdc71 100644 --- a/package.json +++ b/package.json @@ -79,12 +79,14 @@ }, "devDependencies": { "@types/node": "^20.6.0", + "@types/supertest": "^2.0.12", "@vitest/coverage-v8": "^0.34.4", "bumpp": "^9.2.0", "cookie-es": "^1.0.0", "gh-changelogen": "^0.2.8", "h3": "^1.8.1", "lint-staged": "^14.0.0", + "supertest": "^6.3.3", "typescript": "^5.2.2", "unbuild": "^2.0.0", "vitest": "^0.34.4" diff --git a/src/h3.test.ts b/src/h3.test.ts index 99f07d5..495c981 100644 --- a/src/h3.test.ts +++ b/src/h3.test.ts @@ -1,8 +1,16 @@ -import { describe, expect, test } from 'vitest' -import { getAcceptLanguages, getCookieLocale, getLocale } from './h3.ts' +import { beforeEach, describe, expect, test } from 'vitest' +import { createApp, eventHandler, toNodeListener } from 'h3' +import supertest from 'supertest' +import { + getAcceptLanguages, + getCookieLocale, + getLocale, + setCookieLocale, +} from './h3.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' -import type { H3Event } from 'h3' +import type { App, H3Event } from 'h3' +import type { SuperTest, Test } from 'supertest' describe('getAcceptLanguages', () => { test('basic', () => { @@ -192,3 +200,70 @@ describe('getCookieLocale', () => { .toThrowError(RangeError) }) }) + +describe('setCookieLocale', () => { + let app: App + let request: SuperTest + + beforeEach(() => { + app = createApp({ debug: false }) + request = supertest(toNodeListener(app)) + }) + + test('specify Locale instance', async () => { + app.use( + '/', + eventHandler((event) => { + const locale = new Intl.Locale('ja-JP') + setCookieLocale(event, locale) + return '200' + }), + ) + const result = await request.get('/') + expect(result.headers['set-cookie']).toEqual([ + 'i18n_locale=ja-JP; Path=/', + ]) + }) + + test('specify language tag', async () => { + app.use( + '/', + eventHandler((event) => { + setCookieLocale(event, 'ja-JP') + return '200' + }), + ) + const result = await request.get('/') + expect(result.headers['set-cookie']).toEqual([ + 'i18n_locale=ja-JP; Path=/', + ]) + }) + + test('specify cookie name', async () => { + app.use( + '/', + eventHandler((event) => { + setCookieLocale(event, 'ja-JP', { name: 'intlify_locale' }) + return '200' + }), + ) + const result = await request.get('/') + expect(result.headers['set-cookie']).toEqual([ + 'intlify_locale=ja-JP; Path=/', + ]) + }) + + test('Syntax Error', () => { + const eventMock = { + node: { + req: { + method: 'GET', + headers: {}, + }, + }, + } as H3Event + + expect(() => setCookieLocale(eventMock, 'j')) + .toThrowError(/locale is invalid: j/) + }) +}) diff --git a/src/h3.ts b/src/h3.ts index 0a17d57..afc77eb 100644 --- a/src/h3.ts +++ b/src/h3.ts @@ -1,8 +1,13 @@ -import { getAcceptLanguagesWithGetter, getLocaleWithGetter } from './http.ts' -import { getCookie, getHeaders } from 'h3' +import { + getAcceptLanguagesWithGetter, + getLocaleWithGetter, + validateLocale, +} from './http.ts' +import { getCookie, getHeaders, setCookie } from 'h3' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' import type { H3Event } from 'h3' +import type { CookieOptions } from './http.ts' /** * get accpet languages @@ -97,3 +102,34 @@ export function getCookieLocale( ): Intl.Locale { return getLocaleWithGetter(() => getCookie(event, name) || lang) } + +/** + * set locale to the response `Set-Cookie` header. + * + * @example + * example for h3: + * + * ```ts + * import { createApp, eventHandler } from 'h3' + * import { getCookieLocale } from '@intlify/utils/h3' + * + * app.use(eventHandler(event) => { + * setCookieLocale(event, 'ja-JP') + * // ... + * }) + * ``` + * + * @param {H3Event} event The {@link H3Event | H3} event + * @param {string | Intl.Locale} locale The locale value + * @param {CookieOptions} options The cookie options, `name` option is `i18n_locale` as default, and `path` option is `/` as default. + * + * @throws {SyntaxError} Throws the {@link SyntaxError} if `locale` is invalid. + */ +export function setCookieLocale( + event: H3Event, + locale: string | Intl.Locale, + options: CookieOptions = { name: DEFAULT_COOKIE_NAME, path: '/' }, +): void { + validateLocale(locale) + setCookie(event, options.name!, locale.toString(), options) +} diff --git a/src/http.ts b/src/http.ts index 48ae9ed..0a9e742 100644 --- a/src/http.ts +++ b/src/http.ts @@ -1,4 +1,107 @@ import { parseAcceptLanguage } from './shared.ts' +import { isLocale, validateLanguageTag } from './shared.ts' + +// import type { CookieSerializeOptions } from 'cookie-es' +// NOTE: This is a copy of the type definition from `cookie-es` package, we want to avoid building error for this type definition ... + +interface CookieSerializeOptions { + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.3|Domain Set-Cookie attribute}. By default, no + * domain is set, and most clients will consider the cookie to apply to only + * the current domain. + */ + domain?: string | undefined + /** + * Specifies a function that will be used to encode a cookie's value. Since + * value of a cookie has a limited character set (and must be a simple + * string), this function can be used to encode a value into a string suited + * for a cookie's value. + * + * The default function is the global `encodeURIComponent`, which will + * encode a JavaScript string into UTF-8 byte sequences and then URL-encode + * any that fall outside of the cookie range. + */ + encode?(value: string): string + /** + * Specifies the `Date` object to be the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.1|`Expires` `Set-Cookie` attribute}. By default, + * no expiration is set, and most clients will consider this a "non-persistent cookie" and will delete + * it on a condition like exiting a web browser application. + * + * *Note* the {@link https://tools.ietf.org/html/rfc6265#section-5.3|cookie storage model specification} + * states that if both `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + expires?: Date | undefined + /** + * Specifies the boolean value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.6|`HttpOnly` `Set-Cookie` attribute}. + * When truthy, the `HttpOnly` attribute is set, otherwise it is not. By + * default, the `HttpOnly` attribute is not set. + * + * *Note* be careful when setting this to true, as compliant clients will + * not allow client-side JavaScript to see the cookie in `document.cookie`. + */ + httpOnly?: boolean | undefined + /** + * Specifies the number (in seconds) to be the value for the `Max-Age` + * `Set-Cookie` attribute. The given number will be converted to an integer + * by rounding down. By default, no maximum age is set. + * + * *Note* the {@link https://tools.ietf.org/html/rfc6265#section-5.3|cookie storage model specification} + * states that if both `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + maxAge?: number | undefined + /** + * Specifies the value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.4|`Path` `Set-Cookie` attribute}. + * By default, the path is considered the "default path". + */ + path?: string | undefined + /** + * Specifies the `string` to be the value for the [`Priority` `Set-Cookie` attribute][rfc-west-cookie-priority-00-4.1]. + * + * - `'low'` will set the `Priority` attribute to `Low`. + * - `'medium'` will set the `Priority` attribute to `Medium`, the default priority when not set. + * - `'high'` will set the `Priority` attribute to `High`. + * + * More information about the different priority levels can be found in + * [the specification][rfc-west-cookie-priority-00-4.1]. + * + * **note** This is an attribute that has not yet been fully standardized, and may change in the future. + * This also means many clients may ignore this attribute until they understand it. + */ + priority?: 'low' | 'medium' | 'high' | undefined + /** + * Specifies the boolean or string to be the value for the {@link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7|`SameSite` `Set-Cookie` attribute}. + * + * - `true` will set the `SameSite` attribute to `Strict` for strict same + * site enforcement. + * - `false` will not set the `SameSite` attribute. + * - `'lax'` will set the `SameSite` attribute to Lax for lax same site + * enforcement. + * - `'strict'` will set the `SameSite` attribute to Strict for strict same + * site enforcement. + * - `'none'` will set the SameSite attribute to None for an explicit + * cross-site cookie. + * + * More information about the different enforcement levels can be found in {@link https://tools.ietf.org/html/draft-ietf-httpbis-rfc6265bis-03#section-4.1.2.7|the specification}. + * + * *note* This is an attribute that has not yet been fully standardized, and may change in the future. This also means many clients may ignore this attribute until they understand it. + */ + sameSite?: true | false | 'lax' | 'strict' | 'none' | undefined + /** + * Specifies the boolean value for the {@link https://tools.ietf.org/html/rfc6265#section-5.2.5|`Secure` `Set-Cookie` attribute}. When truthy, the + * `Secure` attribute is set, otherwise it is not. By default, the `Secure` attribute is not set. + * + * *Note* be careful when setting this to `true`, as compliant clients will + * not send the cookie back to the server in the future if the browser does + * not have an HTTPS connection. + */ + secure?: boolean | undefined +} + +export type CookieOptions = CookieSerializeOptions & { name?: string } export function getAcceptLanguagesWithGetter( getter: () => string | null | undefined, @@ -10,3 +113,26 @@ export function getAcceptLanguagesWithGetter( export function getLocaleWithGetter(getter: () => string): Intl.Locale { return new Intl.Locale(getter()) } + +export function validateLocale(locale: string | Intl.Locale): void { + if ( + !(isLocale(locale) || + typeof locale === 'string' && validateLanguageTag(locale)) + ) { + throw new SyntaxError(`locale is invalid: ${locale.toString()}`) + } +} + +export function getExistCookies( + name: string, + getter: () => unknown, +) { + let setCookies = getter() + if (!Array.isArray(setCookies)) { + setCookies = [setCookies] + } + setCookies = (setCookies as string[]).filter((cookieValue: string) => + cookieValue && !cookieValue.startsWith(name + '=') + ) + return setCookies as string[] +} diff --git a/src/node.test.ts b/src/node.test.ts index c5f62dc..3f89609 100644 --- a/src/node.test.ts +++ b/src/node.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from 'vitest' -import { getAcceptLanguages, getCookieLocale, getLocale } from './node.ts' -import { IncomingMessage } from 'node:http' +import supertest from 'supertest' +import { + getAcceptLanguages, + getCookieLocale, + getLocale, + setCookieLocale, +} from './node.ts' +import { createServer, IncomingMessage, OutgoingMessage } from 'node:http' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' describe('getAcceptLanguages', () => { @@ -132,3 +138,52 @@ describe('getCookieLocale', () => { .toThrowError(RangeError) }) }) + +describe('setCookieLocale', () => { + test('specify Locale instance', async () => { + const server = createServer((_req, res) => { + const locale = new Intl.Locale('ja-JP') + setCookieLocale(res, locale) + res.writeHead(200) + res.end('hello world!') + }) + const request = supertest(server) + const result = await request.get('/') + expect(result.headers['set-cookie']).toEqual([ + `${DEFAULT_COOKIE_NAME}=ja-JP; Path=/`, + ]) + }) + + test('specify language tag', async () => { + const server = createServer((_req, res) => { + setCookieLocale(res, 'ja-JP') + res.writeHead(200) + res.end('hello world!') + }) + const request = supertest(server) + const result = await request.get('/') + expect(result.headers['set-cookie']).toEqual([ + `${DEFAULT_COOKIE_NAME}=ja-JP; Path=/`, + ]) + }) + + test('specify cookie name', async () => { + const server = createServer((_req, res) => { + setCookieLocale(res, 'ja-JP', { name: 'intlify_locale' }) + res.writeHead(200) + res.end('hello world!') + }) + const request = supertest(server) + const result = await request.get('/') + expect(result.headers['set-cookie']).toEqual([ + 'intlify_locale=ja-JP; Path=/', + ]) + }) + + test('Syntax Error', () => { + const mockRes = {} as OutgoingMessage + + expect(() => setCookieLocale(mockRes, 'j')) + .toThrowError(/locale is invalid: j/) + }) +}) diff --git a/src/node.ts b/src/node.ts index 6bbad1b..af28ba3 100644 --- a/src/node.ts +++ b/src/node.ts @@ -1,8 +1,15 @@ -import { IncomingMessage } from 'node:http' -import { parse } from 'cookie-es' -import { getAcceptLanguagesWithGetter, getLocaleWithGetter } from './http.ts' +import { IncomingMessage, OutgoingMessage } from 'node:http' +import { parse, serialize } from 'cookie-es' +import { + getAcceptLanguagesWithGetter, + getExistCookies, + getLocaleWithGetter, + validateLocale, +} from './http.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' +import type { CookieOptions } from './http.ts' + /** * get accpet languages * @@ -97,3 +104,42 @@ export function getCookieLocale( } return getLocaleWithGetter(getter) } + +/** + * set locale to the response `Set-Cookie` header. + * + * @example + * example for Node.js response: + * + * ```ts + * import { createServer } from 'node:http' + * import { setCookieLocale } from '@intlify/utils/node' + * + * const server = createServer((req, res) => { + * setCookieLocale(res, 'ja-JP') + * // ... + * }) + * ``` + * + * @param {OutgoingMessage} res The {@link OutgoingMessage | response} + * @param {string | Intl.Locale} locale The locale value + * @param {CookieOptions} options The cookie options, `name` option is `i18n_locale` as default, and `path` option is `/` as default. + * + * @throws {SyntaxError} Throws the {@link SyntaxError} if `locale` is invalid. + */ +export function setCookieLocale( + res: OutgoingMessage, + locale: string | Intl.Locale, + options: CookieOptions = { name: DEFAULT_COOKIE_NAME }, +): void { + validateLocale(locale) + const setCookies = getExistCookies( + options.name!, + () => res.getHeader('set-cookie'), + ) + const target = serialize(options.name!, locale.toString(), { + path: '/', + ...options, + }) + res.setHeader('set-cookie', [...setCookies, target]) +} diff --git a/src/web.test.ts b/src/web.test.ts index e9df43d..d5ddb1c 100644 --- a/src/web.test.ts +++ b/src/web.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from 'vitest' -import { getAcceptLanguages, getCookieLocale, getLocale } from './web.ts' +import { + getAcceptLanguages, + getCookieLocale, + getLocale, + setCookieLocale, +} from './web.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' describe('getAcceptLanguages', () => { @@ -96,3 +101,36 @@ describe('getCookieLocale', () => { .toThrowError(RangeError) }) }) + +describe('setCookieLocale', () => { + test('specify Locale instance', () => { + const res = new Response('hello world!') + const locale = new Intl.Locale('ja-JP') + setCookieLocale(res, locale) + expect(res.headers.getSetCookie()).toEqual([ + `${DEFAULT_COOKIE_NAME}=ja-JP; Path=/`, + ]) + }) + + test('specify language tag', () => { + const res = new Response('hello world!') + setCookieLocale(res, 'ja-JP') + expect(res.headers.getSetCookie()).toEqual([ + `${DEFAULT_COOKIE_NAME}=ja-JP; Path=/`, + ]) + }) + + test('specify cookie name', () => { + const res = new Response('hello world!') + setCookieLocale(res, 'ja-JP', { name: 'intlify_locale' }) + expect(res.headers.getSetCookie()).toEqual([ + 'intlify_locale=ja-JP; Path=/', + ]) + }) + + test('Syntax Error', () => { + const res = new Response('hello world!') + expect(() => setCookieLocale(res, 'j')) + .toThrowError(/locale is invalid: j/) + }) +}) diff --git a/src/web.ts b/src/web.ts index 553f9de..382cb45 100644 --- a/src/web.ts +++ b/src/web.ts @@ -1,7 +1,14 @@ -import { parse } from 'cookie-es' -import { getAcceptLanguagesWithGetter, getLocaleWithGetter } from './http.ts' +import { parse, serialize } from 'cookie-es' +import { + getAcceptLanguagesWithGetter, + getExistCookies, + getLocaleWithGetter, + validateLocale, +} from './http.ts' import { DEFAULT_COOKIE_NAME, DEFAULT_LANG_TAG } from './constants.ts' +import type { CookieOptions } from './http.ts' + /** * get accpet languages * @@ -96,3 +103,46 @@ export function getCookieLocale( } return getLocaleWithGetter(getter) } + +/** + * set locale to the response `Set-Cookie` header. + * + * @example + * example for Web API response on Bun: + * + * ```ts + * import { setCookieLocale } from '@intlify/utils/web' + * + * Bun.serve({ + * port: 8080, + * fetch(req) { + * const res = new Response('こんにちは、世界!') + * setCookieLocale(res, 'ja-JP') + * // ... + * return res + * }, + * }) + * ``` + * + * @param {Response} res The {@link Response | response} + * @param {string | Intl.Locale} locale The locale value + * @param {CookieOptions} options The cookie options, `name` option is `i18n_locale` as default, and `path` option is `/` as default. + * + * @throws {SyntaxError} Throws the {@link SyntaxError} if `locale` is invalid. + */ +export function setCookieLocale( + res: Response, + locale: string | Intl.Locale, + options: CookieOptions = { name: DEFAULT_COOKIE_NAME }, +): void { + validateLocale(locale) + const setCookies = getExistCookies( + options.name!, + () => res.headers.getSetCookie(), + ) + const target = serialize(options.name!, locale.toString(), { + path: '/', + ...options, + }) + res.headers.set('set-cookie', [...setCookies, target].join('; ')) +}