Skip to content

Commit

Permalink
feat: add setCookieLocale
Browse files Browse the repository at this point in the history
  • Loading branch information
kazupon committed Sep 21, 2023
1 parent 9ddf761 commit e57abfe
Show file tree
Hide file tree
Showing 9 changed files with 441 additions and 13 deletions.
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
81 changes: 78 additions & 3 deletions src/h3.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -192,3 +200,70 @@ describe('getCookieLocale', () => {
.toThrowError(RangeError)
})
})

describe('setCookieLocale', () => {
let app: App
let request: SuperTest<Test>

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/)
})
})
40 changes: 38 additions & 2 deletions src/h3.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
126 changes: 126 additions & 0 deletions src/http.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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[]
}
59 changes: 57 additions & 2 deletions src/node.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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/)
})
})
Loading

0 comments on commit e57abfe

Please sign in to comment.