chore: add lint rule and format (#367)

* chore: add lint rule and format

* fix ci

* add typescript

* yarn install

* add `@cloudflare/workers-types`
pull/373/head
Yusuke Wada 2024-01-29 22:53:43 +09:00 committed by GitHub
parent fce4d55b9a
commit e8b494b207
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 5164 additions and 189 deletions

View File

@ -21,8 +21,5 @@ jobs:
with: with:
node-version: 18.x node-version: 18.x
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Build zod-validator in root directory
run: yarn build:zod-validator
working-directory: .
- run: yarn build - run: yarn build
- run: yarn test - run: yarn test

View File

@ -13,7 +13,7 @@ export class ClientSessionError extends AuthError {}
export interface AuthClientConfig { export interface AuthClientConfig {
baseUrl: string baseUrl: string
basePath: string basePath: string
credentials?: RequestCredentials, credentials?: RequestCredentials
/** Stores last session response */ /** Stores last session response */
_session?: Session | null | undefined _session?: Session | null | undefined
/** Used for timestamp since last sycned (in seconds) */ /** Used for timestamp since last sycned (in seconds) */
@ -33,9 +33,7 @@ export interface UseSessionOptions<R extends boolean> {
// Util type that matches some strings literally, but allows any other string as well. // Util type that matches some strings literally, but allows any other string as well.
// @source https://github.com/microsoft/TypeScript/issues/29729#issuecomment-832522611 // @source https://github.com/microsoft/TypeScript/issues/29729#issuecomment-832522611
export type LiteralUnion<T extends U, U = string> = export type LiteralUnion<T extends U, U = string> = T | (U & Record<never, never>)
| T
| (U & Record<never, never>)
export interface ClientSafeProvider { export interface ClientSafeProvider {
id: LiteralUnion<BuiltInProviderType> id: LiteralUnion<BuiltInProviderType>
@ -137,7 +135,9 @@ export async function fetchData<T = any>(
const res = await fetch(url, options) const res = await fetch(url, options)
const data = await res.json() const data = await res.json()
if (!res.ok) throw data if (!res.ok) {
throw data
}
return data as T return data as T
} catch (error) { } catch (error) {
logger.error(new ClientFetchError((error as Error).message, error as any)) logger.error(new ClientFetchError((error as Error).message, error as any))

View File

@ -68,8 +68,7 @@ export async function getAuthUser(c: Context): Promise<AuthUser | null> {
...config.callbacks, ...config.callbacks,
async session(...args) { async session(...args) {
authUser = args[0] authUser = args[0]
const session = const session = (await config.callbacks?.session?.(...args)) ?? args[0].session
(await config.callbacks?.session?.(...args)) ?? args[0].session
const user = args[0].user ?? args[0].token const user = args[0].user ?? args[0].token
return { user, ...session } satisfies Session return { user, ...session } satisfies Session
}, },
@ -90,7 +89,9 @@ export function verifyAuth(): MiddlewareHandler {
status: 401, status: 401,
}) })
throw new HTTPException(401, { res }) throw new HTTPException(401, { res })
} else c.set('authUser', authUser) } else {
c.set('authUser', authUser)
}
await next() await next()
} }

View File

@ -33,7 +33,7 @@ class AuthConfigManager {
_config: AuthClientConfig = { _config: AuthClientConfig = {
baseUrl: parseUrl(window.location.origin).origin, baseUrl: parseUrl(window.location.origin).origin,
basePath: parseUrl(window.location.origin).path, basePath: parseUrl(window.location.origin).path,
credentials:'same-origin', credentials: 'same-origin',
_lastSync: 0, _lastSync: 0,
_session: undefined, _session: undefined,
_getSession: () => {}, _getSession: () => {},
@ -112,8 +112,11 @@ export function useSession<R extends boolean>(
error: 'SessionRequired', error: 'SessionRequired',
callbackUrl: window.location.href, callbackUrl: window.location.href,
})}` })}`
if (onUnauthenticated) onUnauthenticated() if (onUnauthenticated) {
else window.location.href = url onUnauthenticated()
} else {
window.location.href = url
}
} }
}, [requiredAndNotLoading, onUnauthenticated]) }, [requiredAndNotLoading, onUnauthenticated])
@ -153,7 +156,11 @@ export async function getSession(params?: GetSessionParams) {
* @internal * @internal
*/ */
export async function getCsrfToken() { export async function getCsrfToken() {
const response = await fetchData<{ csrfToken: string }>('csrf', authConfigManager.getConfig(), logger) const response = await fetchData<{ csrfToken: string }>(
'csrf',
authConfigManager.getConfig(),
logger
)
return response?.csrfToken ?? '' return response?.csrfToken ?? ''
} }
@ -215,7 +222,9 @@ export async function signIn<P extends RedirectableProviderType | undefined = un
const url = (data as any).url ?? callbackUrl const url = (data as any).url ?? callbackUrl
window.location.href = url window.location.href = url
// If url contains a hash, the browser does not reload the page. We reload manually // If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) window.location.reload() if (url.includes('#')) {
window.location.reload()
}
return return
} }
@ -261,7 +270,9 @@ export async function signOut<R extends boolean = true>(
const url = (data as any).url ?? callbackUrl const url = (data as any).url ?? callbackUrl
window.location.href = url window.location.href = url
// If url contains a hash, the browser does not reload the page. We reload manually // If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) window.location.reload() if (url.includes('#')) {
window.location.reload()
}
// @ts-expect-error TODO: Fix this // @ts-expect-error TODO: Fix this
return return
} }
@ -285,11 +296,12 @@ export function SessionProvider(props: SessionProviderProps) {
__AUTHJS._lastSync = hasInitialSession ? now() : 0 __AUTHJS._lastSync = hasInitialSession ? now() : 0
const [session, setSession] = React.useState(() => { const [session, setSession] = React.useState(() => {
if (hasInitialSession) __AUTHJS._session = props.session if (hasInitialSession) {
__AUTHJS._session = props.session
}
return props.session return props.session
}) })
const [loading, setLoading] = React.useState(!hasInitialSession) const [loading, setLoading] = React.useState(!hasInitialSession)
React.useEffect(() => { React.useEffect(() => {
@ -363,9 +375,10 @@ export function SessionProvider(props: SessionProviderProps) {
// and makes our tab visible again, re-fetch the session, but only if // and makes our tab visible again, re-fetch the session, but only if
// this feature is not disabled. // this feature is not disabled.
const visibilityHandler = () => { const visibilityHandler = () => {
if (refetchOnWindowFocus && document.visibilityState === 'visible') if (refetchOnWindowFocus && document.visibilityState === 'visible') {
__AUTHJS._getSession({ event: 'visibilitychange' }) __AUTHJS._getSession({ event: 'visibilitychange' })
} }
}
document.addEventListener('visibilitychange', visibilityHandler, false) document.addEventListener('visibilitychange', visibilityHandler, false)
return () => document.removeEventListener('visibilitychange', visibilityHandler, false) return () => document.removeEventListener('visibilitychange', visibilityHandler, false)
}, [props.refetchOnWindowFocus]) }, [props.refetchOnWindowFocus])
@ -390,7 +403,9 @@ export function SessionProvider(props: SessionProviderProps) {
data: session, data: session,
status: loading ? 'loading' : session ? 'authenticated' : 'unauthenticated', status: loading ? 'loading' : session ? 'authenticated' : 'unauthenticated',
async update(data: any) { async update(data: any) {
if (loading || !session) return if (loading || !session) {
return
}
setLoading(true) setLoading(true)
const newSession = await fetchData<Session>( const newSession = await fetchData<Session>(
'session', 'session',

View File

@ -1,8 +1,7 @@
import {webcrypto} from 'node:crypto' import { webcrypto } from 'node:crypto'
import { Hono } from 'hono' import { Hono } from 'hono'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { authHandler, verifyAuth,initAuthConfig} from '../src' import { authHandler, verifyAuth, initAuthConfig } from '../src'
// @ts-expect-error - global crypto // @ts-expect-error - global crypto
//needed for node 18 and below but should work in node 20 and above //needed for node 18 and below but should work in node 20 and above
@ -37,7 +36,7 @@ describe('Auth.js Adapter Middleware', () => {
const app = new Hono() const app = new Hono()
app.use('/*', (c, next) => { app.use('/*', (c, next) => {
c.env = {'AUTH_SECRET':'secret'} c.env = { AUTH_SECRET: 'secret' }
return next() return next()
}) })
@ -60,7 +59,7 @@ describe('Auth.js Adapter Middleware', () => {
const app = new Hono() const app = new Hono()
app.use('/*', (c, next) => { app.use('/*', (c, next) => {
c.env = {'AUTH_SECRET':'secret'} c.env = { AUTH_SECRET: 'secret' }
return next() return next()
}) })
@ -77,7 +76,7 @@ describe('Auth.js Adapter Middleware', () => {
app.use('/api/auth/*', authHandler()) app.use('/api/auth/*', authHandler())
app.get('/api/protected', (c)=> c.text('protected')) app.get('/api/protected', (c) => c.text('protected'))
const req = new Request('http://localhost/api/protected') const req = new Request('http://localhost/api/protected')
const res = await app.request(req) const res = await app.request(req)
expect(res.status).toBe(401) expect(res.status).toBe(401)

View File

@ -39,5 +39,8 @@
"hono": "^3.11.7", "hono": "^3.11.7",
"tsup": "^8.0.1", "tsup": "^8.0.1",
"vitest": "^1.0.4" "vitest": "^1.0.4"
},
"engines": {
"node": ">=18.14.1"
} }
} }

View File

@ -23,7 +23,9 @@ export const bunTranspiler = (options?: BunTranspilerOptions) => {
const extensions = options?.extensions ?? defaultOptions.extensions const extensions = options?.extensions ?? defaultOptions.extensions
const headers = options?.headers ?? defaultOptions.headers const headers = options?.headers ?? defaultOptions.headers
if (extensions?.every((ext) => !url.pathname.endsWith(ext))) return if (extensions?.every((ext) => !url.pathname.endsWith(ext))) {
return
}
try { try {
const loader = url.pathname.split('.').pop() as Bun.TranspilerOptions['loader'] const loader = url.pathname.split('.').pop() as Bun.TranspilerOptions['loader']

View File

@ -25,7 +25,9 @@ export const esbuildTranspiler = (options?: EsbuildTranspilerOptions) => {
const url = new URL(c.req.url) const url = new URL(c.req.url)
const extensions = options?.extensions ?? ['.ts', '.tsx'] const extensions = options?.extensions ?? ['.ts', '.tsx']
if (extensions.every((ext) => !url.pathname.endsWith(ext))) return if (extensions.every((ext) => !url.pathname.endsWith(ext))) {
return
}
const headers = { 'content-type': options?.contentType ?? 'text/javascript' } const headers = { 'content-type': options?.contentType ?? 'text/javascript' }
const script = await c.res.text() const script = await c.res.text()

View File

@ -85,6 +85,8 @@ const setFirebaseToken = (c: Context, idToken: FirebaseIdToken) => c.set(idToken
export const getFirebaseToken = (c: Context): FirebaseIdToken | null => { export const getFirebaseToken = (c: Context): FirebaseIdToken | null => {
const idToken = c.get(idTokenContextKey) const idToken = c.get(idTokenContextKey)
if (!idToken) return null if (!idToken) {
return null
}
return idToken return idToken
} }

View File

@ -96,7 +96,9 @@ const TestSchema = new GraphQLSchema({
const urlString = (query?: Record<string, string>): string => { const urlString = (query?: Record<string, string>): string => {
const base = 'http://localhost/graphql' const base = 'http://localhost/graphql'
if (!query) return base if (!query) {
return base
}
const queryString = new URLSearchParams(query).toString() const queryString = new URLSearchParams(query).toString()
return `${base}?${queryString}` return `${base}?${queryString}`
} }

View File

@ -159,7 +159,9 @@ const TestSchema = new GraphQLSchema({
const urlString = (query?: Record<string, string>): string => { const urlString = (query?: Record<string, string>): string => {
const base = 'http://localhost/graphql' const base = 'http://localhost/graphql'
if (!query) return base if (!query) {
return base
}
const queryString = new URLSearchParams(query).toString() const queryString = new URLSearchParams(query).toString()
return `${base}?${queryString}` return `${base}?${queryString}`
} }

View File

@ -84,10 +84,15 @@ export class AuthFlow {
body: parsedOptions, body: parsedOptions,
}).then((res) => res.json())) as DiscordTokenResponse | DiscordErrorResponse }).then((res) => res.json())) as DiscordTokenResponse | DiscordErrorResponse
if ('error_description' in response) if ('error_description' in response) {
throw new HTTPException(400, { message: response.error_description }) throw new HTTPException(400, { message: response.error_description })
if ('error' in response) throw new HTTPException(400, { message: response.error }) }
if ('message' in response) throw new HTTPException(400, { message: response.message }) if ('error' in response) {
throw new HTTPException(400, { message: response.error })
}
if ('message' in response) {
throw new HTTPException(400, { message: response.message })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {
@ -103,7 +108,9 @@ export class AuthFlow {
} }
} }
if ('scope' in response) this.granted_scopes = response.scope.split(' ') if ('scope' in response) {
this.granted_scopes = response.scope.split(' ')
}
} }
async getUserData() { async getUserData() {
@ -114,11 +121,18 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as DiscordMeResponse | DiscordErrorResponse }).then((res) => res.json())) as DiscordMeResponse | DiscordErrorResponse
if ('error_description' in response) if ('error_description' in response) {
throw new HTTPException(400, { message: response.error_description }) throw new HTTPException(400, { message: response.error_description })
if ('error' in response) throw new HTTPException(400, { message: response.error }) }
if ('message' in response) throw new HTTPException(400, { message: response.message }) if ('error' in response) {
throw new HTTPException(400, { message: response.error })
}
if ('message' in response) {
throw new HTTPException(400, { message: response.message })
}
if ('user' in response) this.user = response.user if ('user' in response) {
this.user = response.user
}
} }
} }

View File

@ -22,7 +22,9 @@ export async function refreshToken(
body: params, body: params,
}).then((res) => res.json())) as DiscordTokenResponse | { error: string } }).then((res) => res.json())) as DiscordTokenResponse | { error: string }
if ('error' in response) throw new HTTPException(400, { message: response.error }) if ('error' in response) {
throw new HTTPException(400, { message: response.error })
}
return response return response
} }

View File

@ -21,7 +21,9 @@ export async function revokeToken(
body: params, body: params,
}) })
if (response.status !== 200) throw new HTTPException(400, { message: 'Something went wrong' }) if (response.status !== 200) {
throw new HTTPException(400, { message: 'Something went wrong' })
}
return true return true
} }

View File

@ -78,7 +78,9 @@ export class AuthFlow {
| FacebookTokenResponse | FacebookTokenResponse
| FacebookErrorResponse | FacebookErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message }) if ('error' in response) {
throw new HTTPException(400, { message: response.error?.message })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {
@ -93,9 +95,13 @@ export class AuthFlow {
`https://graph.facebook.com/v18.0/me?access_token=${this.token?.token}` `https://graph.facebook.com/v18.0/me?access_token=${this.token?.token}`
).then((res) => res.json())) as FacebookMeResponse | FacebookErrorResponse ).then((res) => res.json())) as FacebookMeResponse | FacebookErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message }) if ('error' in response) {
throw new HTTPException(400, { message: response.error?.message })
}
if ('id' in response) this.user = response if ('id' in response) {
this.user = response
}
} }
async getUserData() { async getUserData() {
@ -107,8 +113,12 @@ export class AuthFlow {
`https://graph.facebook.com/${this.user?.id}?fields=${parsedFields}&access_token=${this.token?.token}` `https://graph.facebook.com/${this.user?.id}?fields=${parsedFields}&access_token=${this.token?.token}`
).then((res) => res.json())) as FacebookUser | FacebookErrorResponse ).then((res) => res.json())) as FacebookUser | FacebookErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message }) if ('error' in response) {
throw new HTTPException(400, { message: response.error?.message })
}
if ('id' in response) this.user = response if ('id' in response) {
this.user = response
}
} }
} }

View File

@ -65,8 +65,9 @@ export class AuthFlow {
headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
}).then((res) => res.json())) as GitHubTokenResponse | GitHubErrorResponse }).then((res) => res.json())) as GitHubTokenResponse | GitHubErrorResponse
if ('error_description' in response) if ('error_description' in response) {
throw new HTTPException(400, { message: response.error_description }) throw new HTTPException(400, { message: response.error_description })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {
@ -85,7 +86,9 @@ export class AuthFlow {
} }
async getUserData() { async getUserData() {
if (!this.token?.token) await this.getTokenFromCode() if (!this.token?.token) {
await this.getTokenFromCode()
}
const response = (await fetch('https://api.github.com/user', { const response = (await fetch('https://api.github.com/user', {
headers: { headers: {
@ -96,7 +99,9 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as GitHubUser | GitHubErrorResponse }).then((res) => res.json())) as GitHubUser | GitHubErrorResponse
if ('message' in response) throw new HTTPException(400, { message: response.message }) if ('message' in response) {
throw new HTTPException(400, { message: response.message })
}
if ('id' in response) { if ('id' in response) {
this.user = response this.user = response

View File

@ -91,7 +91,9 @@ export class AuthFlow {
}), }),
}).then((res) => res.json())) as GoogleTokenResponse | GoogleErrorResponse }).then((res) => res.json())) as GoogleTokenResponse | GoogleErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description }) if ('error' in response) {
throw new HTTPException(400, { message: response.error_description })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {
@ -112,8 +114,12 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as GoogleUser | GoogleErrorResponse }).then((res) => res.json())) as GoogleUser | GoogleErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error?.message }) if ('error' in response) {
throw new HTTPException(400, { message: response.error?.message })
}
if ('id' in response) this.user = response if ('id' in response) {
this.user = response
}
} }
} }

View File

@ -79,7 +79,9 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as LinkedInTokenResponse | LinkedInErrorResponse }).then((res) => res.json())) as LinkedInTokenResponse | LinkedInErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description }) if ('error' in response) {
throw new HTTPException(400, { message: response.error_description })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {
@ -106,9 +108,13 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as LinkedInUser | LinkedInErrorResponse }).then((res) => res.json())) as LinkedInUser | LinkedInErrorResponse
if ('message' in response) throw new HTTPException(400, { message: response.message }) if ('message' in response) {
throw new HTTPException(400, { message: response.message })
}
if ('sub' in response) this.user = response if ('sub' in response) {
this.user = response
}
} }
async getAppToken() { async getAppToken() {
@ -122,7 +128,9 @@ export class AuthFlow {
(res) => res.json() (res) => res.json()
)) as LinkedInTokenResponse | LinkedInErrorResponse )) as LinkedInTokenResponse | LinkedInErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description }) if ('error' in response) {
throw new HTTPException(400, { message: response.error_description })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {

View File

@ -93,7 +93,9 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as XTokenResponse | XErrorResponse }).then((res) => res.json())) as XTokenResponse | XErrorResponse
if ('error' in response) throw new HTTPException(400, { message: response.error_description }) if ('error' in response) {
throw new HTTPException(400, { message: response.error_description })
}
if ('access_token' in response) { if ('access_token' in response) {
this.token = { this.token = {
@ -122,9 +124,12 @@ export class AuthFlow {
}, },
}).then((res) => res.json())) as XMeResponse | XErrorResponse }).then((res) => res.json())) as XMeResponse | XErrorResponse
if ('error_description' in response) if ('error_description' in response) {
throw new HTTPException(400, { message: response.error_description }) throw new HTTPException(400, { message: response.error_description })
}
if ('data' in response) this.user = response.data if ('data' in response) {
this.user = response.data
}
} }
} }

View File

@ -21,8 +21,9 @@ export async function refreshToken(
}, },
}).then((res) => res.json())) as XTokenResponse | XErrorResponse }).then((res) => res.json())) as XTokenResponse | XErrorResponse
if ('error_description' in response) if ('error_description' in response) {
throw new HTTPException(400, { message: response.error_description }) throw new HTTPException(400, { message: response.error_description })
}
return response return response
} }

View File

@ -21,8 +21,9 @@ export async function revokeToken(
}, },
}).then((res) => res.json())) as XRevokeResponse | XErrorResponse }).then((res) => res.json())) as XRevokeResponse | XErrorResponse
if ('error_description' in response) if ('error_description' in response) {
throw new HTTPException(400, { message: response.error_description }) throw new HTTPException(400, { message: response.error_description })
}
return response.revoked return response.revoked
} }

View File

@ -1,6 +1,7 @@
import type { DefaultBodyType, StrictResponse } from 'msw' import type { DefaultBodyType, StrictResponse } from 'msw'
import { HttpResponse, http } from 'msw' import { HttpResponse, http } from 'msw'
import type { DiscordErrorResponse, DiscordTokenResponse } from '../src/providers/discord'
import type { import type {
FacebookErrorResponse, FacebookErrorResponse,
FacebookTokenResponse, FacebookTokenResponse,
@ -14,7 +15,6 @@ import type {
} from '../src/providers/google/types' } from '../src/providers/google/types'
import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin' import type { LinkedInErrorResponse, LinkedInTokenResponse } from '../src/providers/linkedin'
import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x' import type { XErrorResponse, XRevokeResponse, XTokenResponse } from '../src/providers/x'
import { DiscordErrorResponse, DiscordTokenResponse } from '../src/providers/discord'
export const handlers = [ export const handlers = [
// Google // Google

View File

@ -1,5 +1,11 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import { setupServer } from 'msw/node' import { setupServer } from 'msw/node'
import type { DiscordUser } from '../src/providers/discord'
import {
refreshToken as discordRefresh,
revokeToken as discordRevoke,
discordAuth,
} from '../src/providers/discord'
import { facebookAuth } from '../src/providers/facebook' import { facebookAuth } from '../src/providers/facebook'
import type { FacebookUser } from '../src/providers/facebook' import type { FacebookUser } from '../src/providers/facebook'
import { githubAuth } from '../src/providers/github' import { githubAuth } from '../src/providers/github'
@ -38,13 +44,6 @@ import {
discordRefreshTokenError, discordRefreshTokenError,
} from './handlers' } from './handlers'
import {
refreshToken as discordRefresh,
revokeToken as discordRevoke,
discordAuth,
DiscordUser,
} from '../src/providers/discord'
const server = setupServer(...handlers) const server = setupServer(...handlers)
server.listen() server.listen()

View File

@ -1,5 +1,5 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import type { Histogram} from 'prom-client' import type { Histogram } from 'prom-client'
import { Registry } from 'prom-client' import { Registry } from 'prom-client'
import { prometheus } from './index' import { prometheus } from './index'
@ -7,9 +7,12 @@ describe('Prometheus middleware', () => {
const app = new Hono() const app = new Hono()
const registry = new Registry() const registry = new Registry()
app.use('*', prometheus({ app.use(
'*',
prometheus({
registry, registry,
}).registerMetrics) }).registerMetrics
)
app.get('/', (c) => c.text('hello')) app.get('/', (c) => c.text('hello'))
app.get('/user/:id', (c) => c.text(c.req.param('id'))) app.get('/user/:id', (c) => c.text(c.req.param('id')))
@ -21,10 +24,13 @@ describe('Prometheus middleware', () => {
const app = new Hono() const app = new Hono()
const registry = new Registry() const registry = new Registry()
app.use('*', prometheus({ app.use(
'*',
prometheus({
registry, registry,
prefix: 'myprefix_', prefix: 'myprefix_',
}).registerMetrics) }).registerMetrics
)
expect(await registry.metrics()).toMatchInlineSnapshot(` expect(await registry.metrics()).toMatchInlineSnapshot(`
"# HELP myprefix_http_request_duration_seconds Duration of HTTP requests in seconds "# HELP myprefix_http_request_duration_seconds Duration of HTTP requests in seconds
@ -40,17 +46,20 @@ describe('Prometheus middleware', () => {
const app = new Hono() const app = new Hono()
const registry = new Registry() const registry = new Registry()
app.use('*', prometheus({ app.use(
'*',
prometheus({
registry, registry,
metricOptions: { metricOptions: {
requestsTotal: { requestsTotal: {
customLabels: { customLabels: {
id: (c) => c.req.query('id') ?? 'unknown', id: (c) => c.req.query('id') ?? 'unknown',
contentType: (c) => c.res.headers.get('content-type') ?? 'unknown', contentType: (c) => c.res.headers.get('content-type') ?? 'unknown',
}
}, },
} },
}).registerMetrics) },
}).registerMetrics
)
app.get('/', (c) => c.text('hello')) app.get('/', (c) => c.text('hello'))
@ -80,7 +89,7 @@ describe('Prometheus middleware', () => {
ok: 'true', ok: 'true',
}, },
value: 1, value: 1,
} },
]) ])
}) })
@ -98,7 +107,7 @@ describe('Prometheus middleware', () => {
ok: 'false', ok: 'false',
}, },
value: 1, value: 1,
} },
]) ])
}) })
}) })
@ -107,10 +116,13 @@ describe('Prometheus middleware', () => {
it('updates the http_requests_duration metric with the correct labels on successful responses', async () => { it('updates the http_requests_duration metric with the correct labels on successful responses', async () => {
await app.request('http://localhost/') await app.request('http://localhost/')
const { values } = await (registry.getSingleMetric('http_request_duration_seconds') as Histogram)!.get()! const { values } = await (registry.getSingleMetric(
'http_request_duration_seconds'
) as Histogram)!.get()!
const countMetric = values.find( const countMetric = values.find(
(v) => v.metricName === 'http_request_duration_seconds_count' && (v) =>
v.metricName === 'http_request_duration_seconds_count' &&
v.labels.method === 'GET' && v.labels.method === 'GET' &&
v.labels.route === '/' && v.labels.route === '/' &&
v.labels.status === '200' v.labels.status === '200'
@ -122,10 +134,13 @@ describe('Prometheus middleware', () => {
it('updates the http_requests_duration metric with the correct labels on errors', async () => { it('updates the http_requests_duration metric with the correct labels on errors', async () => {
await app.request('http://localhost/notfound') await app.request('http://localhost/notfound')
const { values } = await (registry.getSingleMetric('http_request_duration_seconds') as Histogram)!.get()! const { values } = await (registry.getSingleMetric(
'http_request_duration_seconds'
) as Histogram)!.get()!
const countMetric = values.find( const countMetric = values.find(
(v) => v.metricName === 'http_request_duration_seconds_count' && (v) =>
v.metricName === 'http_request_duration_seconds_count' &&
v.labels.method === 'GET' && v.labels.method === 'GET' &&
v.labels.route === '/*' && v.labels.route === '/*' &&
v.labels.status === '404' v.labels.status === '404'
@ -146,8 +161,8 @@ describe('Prometheus middleware', () => {
metricOptions: { metricOptions: {
requestDuration: { requestDuration: {
disabled: true, // Disable duration metrics to make the test result more predictable disabled: true, // Disable duration metrics to make the test result more predictable
} },
} },
}) })
app.use('*', registerMetrics) app.use('*', registerMetrics)

View File

@ -2,19 +2,20 @@ import type { Context } from 'hono'
import { createMiddleware } from 'hono/factory' import { createMiddleware } from 'hono/factory'
import type { DefaultMetricsCollectorConfiguration, RegistryContentType } from 'prom-client' import type { DefaultMetricsCollectorConfiguration, RegistryContentType } from 'prom-client'
import { Registry, collectDefaultMetrics as promCollectDefaultMetrics } from 'prom-client' import { Registry, collectDefaultMetrics as promCollectDefaultMetrics } from 'prom-client'
import { type MetricOptions, type CustomMetricsOptions, createStandardMetrics } from './standardMetrics' import {
type MetricOptions,
type CustomMetricsOptions,
createStandardMetrics,
} from './standardMetrics'
interface PrometheusOptions { interface PrometheusOptions {
registry?: Registry; registry?: Registry
collectDefaultMetrics?: boolean | DefaultMetricsCollectorConfiguration<RegistryContentType>; collectDefaultMetrics?: boolean | DefaultMetricsCollectorConfiguration<RegistryContentType>
prefix?: string; prefix?: string
metricOptions?: Omit<CustomMetricsOptions, 'prefix' | 'register'>; metricOptions?: Omit<CustomMetricsOptions, 'prefix' | 'register'>
} }
const evaluateCustomLabels = ( const evaluateCustomLabels = (customLabels: MetricOptions['customLabels'], context: Context) => {
customLabels: MetricOptions['customLabels'],
context: Context,
) => {
const labels: Record<string, string> = {} const labels: Record<string, string> = {}
for (const [key, fn] of Object.entries(customLabels ?? {})) { for (const [key, fn] of Object.entries(customLabels ?? {})) {
@ -36,7 +37,7 @@ export const prometheus = (options?: PrometheusOptions) => {
promCollectDefaultMetrics({ promCollectDefaultMetrics({
prefix, prefix,
register: registry, register: registry,
...(typeof collectDefaultMetrics === 'object' && collectDefaultMetrics) ...(typeof collectDefaultMetrics === 'object' && collectDefaultMetrics),
}) })
} }
@ -53,7 +54,6 @@ export const prometheus = (options?: PrometheusOptions) => {
try { try {
await next() await next()
} finally { } finally {
const commonLabels = { const commonLabels = {
method: c.req.method, method: c.req.method,
@ -64,14 +64,14 @@ export const prometheus = (options?: PrometheusOptions) => {
timer?.({ timer?.({
...commonLabels, ...commonLabels,
...evaluateCustomLabels(metricOptions?.requestDuration?.customLabels, c) ...evaluateCustomLabels(metricOptions?.requestDuration?.customLabels, c),
}) })
metrics.requestsTotal?.inc({ metrics.requestsTotal?.inc({
...commonLabels, ...commonLabels,
...evaluateCustomLabels(metricOptions?.requestsTotal?.customLabels, c) ...evaluateCustomLabels(metricOptions?.requestsTotal?.customLabels, c),
}) })
} }
}) }),
} }
} }

View File

@ -3,11 +3,11 @@ import type { CounterConfiguration, HistogramConfiguration, Metric } from 'prom-
import { Counter, Histogram, type Registry } from 'prom-client' import { Counter, Histogram, type Registry } from 'prom-client'
export type MetricOptions = { export type MetricOptions = {
disabled?: boolean; disabled?: boolean
customLabels?: Record<string, (c: Context) => string>; customLabels?: Record<string, (c: Context) => string>
} & ( } & (
({ type: 'counter' } & CounterConfiguration<string>) | | ({ type: 'counter' } & CounterConfiguration<string>)
({ type: 'histogram' } & HistogramConfiguration<string>) | ({ type: 'histogram' } & HistogramConfiguration<string>)
) )
const standardMetrics = { const standardMetrics = {
@ -18,7 +18,7 @@ const standardMetrics = {
labelNames: ['method', 'status', 'ok', 'route'], labelNames: ['method', 'status', 'ok', 'route'],
// OpenTelemetry recommendation for histogram buckets of http request duration: // OpenTelemetry recommendation for histogram buckets of http request duration:
// https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestduration // https://opentelemetry.io/docs/specs/semconv/http/http-metrics/#metric-httpserverrequestduration
buckets: [ 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10 ], buckets: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
}, },
requestsTotal: { requestsTotal: {
type: 'counter', type: 'counter',
@ -35,22 +35,25 @@ export type CustomMetricsOptions = {
} }
type CreatedMetrics = { type CreatedMetrics = {
[Name in MetricName]: (typeof standardMetrics)[Name]['type'] extends 'counter' ? Counter<string> : Histogram<string> [Name in MetricName]: (typeof standardMetrics)[Name]['type'] extends 'counter'
? Counter<string>
: Histogram<string>
} }
const getMetricConstructor = (type: MetricOptions['type']) => ({ const getMetricConstructor = (type: MetricOptions['type']) =>
({
counter: Counter, counter: Counter,
histogram: Histogram, histogram: Histogram,
})[type] }[type])
export const createStandardMetrics = ({ export const createStandardMetrics = ({
registry, registry,
prefix = '', prefix = '',
customOptions, customOptions,
} : { }: {
registry: Registry; registry: Registry
prefix?: string; prefix?: string
customOptions?: CustomMetricsOptions; customOptions?: CustomMetricsOptions
}) => { }) => {
const createdMetrics: Record<string, Metric> = {} const createdMetrics: Record<string, Metric> = {}
@ -70,9 +73,10 @@ export const createStandardMetrics = ({
...(opts as object), ...(opts as object),
name: `${prefix}${opts.name}`, name: `${prefix}${opts.name}`,
help: opts.help, help: opts.help,
registers: [...opts.registers ?? [], registry], registers: [...(opts.registers ?? []), registry],
labelNames: [...opts.labelNames ?? [], ...Object.keys(opts.customLabels ?? {})], labelNames: [...(opts.labelNames ?? []), ...Object.keys(opts.customLabels ?? {})],
...(opts.type === 'histogram' && opts.buckets && { ...(opts.type === 'histogram' &&
opts.buckets && {
buckets: opts.buckets, buckets: opts.buckets,
}), }),
}) })

View File

@ -42,7 +42,9 @@ export const sentry = (
...options, ...options,
}) })
c.set('sentry', sentry) c.set('sentry', sentry)
if (callback) callback(sentry) if (callback) {
callback(sentry)
}
await next() await next()
if (c.error) { if (c.error) {

View File

@ -58,7 +58,9 @@ export const renderSwaggerUIOptions = (options: DistSwaggerUIOptions) => {
return `${key}: '${v}'` return `${key}: '${v}'`
} }
if (RENDER_TYPE_MAP[key] === RENDER_TYPE.STRING_ARRAY) { if (RENDER_TYPE_MAP[key] === RENDER_TYPE.STRING_ARRAY) {
if (!Array.isArray(v)) return '' if (!Array.isArray(v)) {
return ''
}
return `${key}: [${v.map((ve) => `${ve}`).join(',')}]` return `${key}: [${v.map((ve) => `${ve}`).join(',')}]`
} }
if (RENDER_TYPE_MAP[key] === RENDER_TYPE.JSON_STRING) { if (RENDER_TYPE_MAP[key] === RENDER_TYPE.JSON_STRING) {

View File

@ -1,39 +1,37 @@
/*eslint quotes: ["off", "single"]*/
import { renderSwaggerUIOptions } from '../src/swagger/renderer' import { renderSwaggerUIOptions } from '../src/swagger/renderer'
describe('SwaggerUIOption Rendering', () => { describe('SwaggerUIOption Rendering', () => {
it('renders correctly with configUrl', () => { it('renders correctly with configUrl', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
configUrl: 'https://petstore3.swagger.io/api/v3/openapi.json', configUrl: 'https://petstore3.swagger.io/api/v3/openapi.json',
}) })
).toEqual('configUrl: \'https://petstore3.swagger.io/api/v3/openapi.json\'') ).toEqual("configUrl: 'https://petstore3.swagger.io/api/v3/openapi.json'"))
})
it('renders correctly with presets', () => { it('renders correctly with presets', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
presets: ['SwaggerUIBundle.presets.apis', 'SwaggerUIStandalonePreset'], presets: ['SwaggerUIBundle.presets.apis', 'SwaggerUIStandalonePreset'],
}) })
).toEqual('presets: [SwaggerUIBundle.presets.apis,SwaggerUIStandalonePreset]') ).toEqual('presets: [SwaggerUIBundle.presets.apis,SwaggerUIStandalonePreset]'))
})
it('renders correctly with plugins', () => { it('renders correctly with plugins', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
plugins: ['SwaggerUIBundle.plugins.DownloadUrl'], plugins: ['SwaggerUIBundle.plugins.DownloadUrl'],
}) })
).toEqual('plugins: [SwaggerUIBundle.plugins.DownloadUrl]') ).toEqual('plugins: [SwaggerUIBundle.plugins.DownloadUrl]'))
})
it('renders correctly with deepLinking', () => { it('renders correctly with deepLinking', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
deepLinking: true, deepLinking: true,
}) })
).toEqual('deepLinking: true') ).toEqual('deepLinking: true'))
})
it('renders correctly with spec', () => { it('renders correctly with spec', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
spec: { spec: {
@ -51,15 +49,14 @@ describe('SwaggerUIOption Rendering', () => {
}) })
).toEqual( ).toEqual(
'spec: {"openapi":"3.0.0","info":{"title":"Swagger Petstore","version":"1.0.0"},"servers":[{"url":"https://petstore3.swagger.io/api/v3"}]}' 'spec: {"openapi":"3.0.0","info":{"title":"Swagger Petstore","version":"1.0.0"},"servers":[{"url":"https://petstore3.swagger.io/api/v3"}]}'
) ))
})
it('renders correctly with url', () => { it('renders correctly with url', () => {
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
url: 'https://petstore3.swagger.io/api/v3/openapi.json', url: 'https://petstore3.swagger.io/api/v3/openapi.json',
}) })
).toEqual('url: \'https://petstore3.swagger.io/api/v3/openapi.json\'') ).toEqual("url: 'https://petstore3.swagger.io/api/v3/openapi.json'")
}) })
it('renders correctly with urls', () => { it('renders correctly with urls', () => {
@ -77,59 +74,52 @@ describe('SwaggerUIOption Rendering', () => {
) )
}) })
it('renders correctly with layout', () => { it('renders correctly with layout', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
layout: 'StandaloneLayout', layout: 'StandaloneLayout',
}) })
).toEqual('layout: \'StandaloneLayout\'') ).toEqual("layout: 'StandaloneLayout'"))
})
it('renders correctly with docExpansion', () => { it('renders correctly with docExpansion', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
docExpansion: 'list', docExpansion: 'list',
}) })
).toEqual('docExpansion: \'list\'') ).toEqual("docExpansion: 'list'"))
})
it('renders correctly with maxDisplayedTags', () => { it('renders correctly with maxDisplayedTags', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
maxDisplayedTags: 5, maxDisplayedTags: 5,
}) })
).toEqual('maxDisplayedTags: 5') ).toEqual('maxDisplayedTags: 5'))
})
it('renders correctly with operationsSorter', () => { it('renders correctly with operationsSorter', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
operationsSorter: '(a, b) => a.path.localeCompare(b.path)', operationsSorter: '(a, b) => a.path.localeCompare(b.path)',
}) })
).toEqual('operationsSorter: (a, b) => a.path.localeCompare(b.path)') ).toEqual('operationsSorter: (a, b) => a.path.localeCompare(b.path)'))
})
it('renders correctly with requestInterceptor', () => { it('renders correctly with requestInterceptor', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
requestInterceptor: '(req) => req', requestInterceptor: '(req) => req',
}) })
).toEqual('requestInterceptor: (req) => req') ).toEqual('requestInterceptor: (req) => req'))
})
it('renders correctly with responseInterceptor', () => { it('renders correctly with responseInterceptor', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
responseInterceptor: '(res) => res', responseInterceptor: '(res) => res',
}) })
).toEqual('responseInterceptor: (res) => res') ).toEqual('responseInterceptor: (res) => res'))
})
it('renders correctly with persistAuthorization', () => { it('renders correctly with persistAuthorization', () =>
expect( expect(
renderSwaggerUIOptions({ renderSwaggerUIOptions({
persistAuthorization: true, persistAuthorization: true,
}) })
).toEqual('persistAuthorization: true') ).toEqual('persistAuthorization: true'))
})
}) })

Binary file not shown.

View File

@ -41,16 +41,18 @@
"zod": "3.*" "zod": "3.*"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20240117.0",
"hono": "^3.11.7", "hono": "^3.11.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"openapi3-ts": "^4.1.2", "openapi3-ts": "^4.1.2",
"tsup": "^8.0.1", "tsup": "^8.0.1",
"typescript": "^5.3.3",
"vitest": "^1.0.1", "vitest": "^1.0.1",
"zod": "^3.22.1" "zod": "^3.22.1"
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^5.5.0", "@asteasolutions/zod-to-openapi": "^5.5.0",
"@hono/zod-validator": "^0.1.11" "@hono/zod-validator": "0.1.11"
}, },
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"

File diff suppressed because it is too large Load Diff

View File

@ -876,6 +876,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@cloudflare/workers-types@npm:^4.20240117.0":
version: 4.20240117.0
resolution: "@cloudflare/workers-types@npm:4.20240117.0"
checksum: 900b796af2ae97257e1f6171b9c37d718c5f8ae064cea8f8d1c48e52ccde01492c5cfa61716fc703d05a6bd92e50a55f7d9e525d2c91919db76e42458c3e8e76
languageName: node
linkType: hard
"@colors/colors@npm:1.5.0": "@colors/colors@npm:1.5.0":
version: 1.5.0 version: 1.5.0
resolution: "@colors/colors@npm:1.5.0" resolution: "@colors/colors@npm:1.5.0"
@ -1688,11 +1695,13 @@ __metadata:
resolution: "@hono/zod-openapi@workspace:packages/zod-openapi" resolution: "@hono/zod-openapi@workspace:packages/zod-openapi"
dependencies: dependencies:
"@asteasolutions/zod-to-openapi": "npm:^5.5.0" "@asteasolutions/zod-to-openapi": "npm:^5.5.0"
"@hono/zod-validator": "npm:^0.1.11" "@cloudflare/workers-types": "npm:^4.20240117.0"
"@hono/zod-validator": "npm:0.1.11"
hono: "npm:^3.11.7" hono: "npm:^3.11.7"
jest: "npm:^29.7.0" jest: "npm:^29.7.0"
openapi3-ts: "npm:^4.1.2" openapi3-ts: "npm:^4.1.2"
tsup: "npm:^8.0.1" tsup: "npm:^8.0.1"
typescript: "npm:^5.3.3"
vitest: "npm:^1.0.1" vitest: "npm:^1.0.1"
zod: "npm:^3.22.1" zod: "npm:^3.22.1"
peerDependencies: peerDependencies:
@ -1701,7 +1710,7 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"@hono/zod-validator@npm:^0.1.11, @hono/zod-validator@workspace:packages/zod-validator": "@hono/zod-validator@npm:0.1.11, @hono/zod-validator@workspace:packages/zod-validator":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@hono/zod-validator@workspace:packages/zod-validator" resolution: "@hono/zod-validator@workspace:packages/zod-validator"
dependencies: dependencies: