mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
Initial commit
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
|
||||
/** Generates a new HTTPException with the given status and message as a JSON response.
|
||||
*
|
||||
* **Example:** `throw newHTTPException(401, 'unauthorized')`
|
||||
*/
|
||||
export function newHTTPException(status: ContentfulStatusCode, message: string): HTTPException {
|
||||
return new HTTPException(status, { message })
|
||||
}
|
||||
|
||||
export interface APIError {
|
||||
success: false
|
||||
error: {
|
||||
message: string
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { WorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
export type LogTagHints = {
|
||||
// add common tags here so that they show up as hints
|
||||
// in `logger.setTags()` and `logger.withTags()`
|
||||
url: string
|
||||
}
|
||||
|
||||
export const logger = new WorkersLogger<LogTagHints>()
|
||||
@@ -0,0 +1,72 @@
|
||||
import { redactUrl } from './url'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { HonoApp } from '../types'
|
||||
|
||||
export interface LogDataRequest {
|
||||
url: string
|
||||
method: string
|
||||
path: string
|
||||
/** Hono route for the request */
|
||||
routePath: string
|
||||
/* URL search params */
|
||||
searchParams: string
|
||||
headers: string
|
||||
/** Eyeball IP address of the request */
|
||||
ip?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logdata from request
|
||||
*/
|
||||
export function getRequestLogData<T extends HonoApp>(
|
||||
c: Context<T>,
|
||||
requestStartTimestamp: number
|
||||
): LogDataRequest {
|
||||
const redactedUrl = redactUrl(c.req.url)
|
||||
return {
|
||||
url: redactedUrl.toString(),
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
routePath: c.req.routePath,
|
||||
searchParams: redactedUrl.searchParams.toString(),
|
||||
headers: stringifyHeaders(c.req.raw.headers),
|
||||
ip:
|
||||
c.req.header('cf-connecting-ip') ||
|
||||
c.req.header('x-real-ip') ||
|
||||
c.req.header('x-forwarded-for'),
|
||||
timestamp: new Date(requestStartTimestamp).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const SENSITIVE_HEADER_NAMES = new Set([
|
||||
'authorization',
|
||||
'proxy-authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'cf-access-jwt-assertion',
|
||||
])
|
||||
|
||||
function isSensitiveHeader(name: string): boolean {
|
||||
const normalizedName = name.toLowerCase()
|
||||
|
||||
return (
|
||||
SENSITIVE_HEADER_NAMES.has(normalizedName) ||
|
||||
normalizedName.includes('token') ||
|
||||
normalizedName.includes('secret') ||
|
||||
normalizedName.includes('jwt') ||
|
||||
normalizedName.includes('signature') ||
|
||||
normalizedName.includes('session') ||
|
||||
normalizedName.endsWith('-key') ||
|
||||
normalizedName.endsWith('_key')
|
||||
)
|
||||
}
|
||||
|
||||
function stringifyHeaders(headers: Headers): string {
|
||||
return JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Array.from(headers, ([name, value]) => [name, isSensitiveHeader(name) ? 'REDACTED' : value])
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Redacts keys from a url */
|
||||
export function redactUrl(_url: URL | string): URL {
|
||||
let url: URL
|
||||
if (typeof _url === 'string') {
|
||||
url = new URL(_url)
|
||||
} else {
|
||||
url = new URL(_url.toString()) // clone
|
||||
}
|
||||
for (const [key] of url.searchParams) {
|
||||
if (/key|token|secret|password|passwd|auth|credential/i.test(key)) {
|
||||
url.searchParams.set(key, 'REDACTED')
|
||||
}
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a URLSearchParams object into an array of "key=value" strings.
|
||||
*
|
||||
* @param searchParams - The URLSearchParams object to convert.
|
||||
* @returns An array of strings, where each string is in the format "key=value".
|
||||
*/
|
||||
export function searchParamsToArray(searchParams: URLSearchParams): string[] {
|
||||
const result: string[] = []
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { HonoApp, SharedHonoEnv, SharedHonoVariables, SharedAppContext } from './types'
|
||||
export { logger } from './helpers/logger'
|
||||
export { getRequestLogData, type LogDataRequest } from './helpers/request'
|
||||
export * from './helpers/errors'
|
||||
export * from './helpers/url'
|
||||
export * from './middleware/withCache'
|
||||
export * from './middleware/withDefaultCors'
|
||||
export * from './middleware/withNotFound'
|
||||
export * from './middleware/withOnError'
|
||||
@@ -0,0 +1,78 @@
|
||||
import { httpStatus } from 'http-codex/status'
|
||||
|
||||
import type { Context, Next } from 'hono'
|
||||
import type { StatusCode } from 'hono/utils/http-status'
|
||||
import type { HonoApp } from '../types'
|
||||
|
||||
/** Caches status: 200 responses for given ttl */
|
||||
export function withCache<T extends HonoApp>(ttl: number) {
|
||||
return async (ctx: Context<T>, next: Next): Promise<Response | void> => {
|
||||
const c = ctx as unknown as Context<HonoApp>
|
||||
|
||||
const cache = await caches.open('default')
|
||||
const reqMatcher = new Request(c.req.url, { method: c.req.method })
|
||||
const cachedRes = await cache.match(reqMatcher)
|
||||
if (cachedRes) {
|
||||
return c.newResponse(cachedRes.body, cachedRes)
|
||||
}
|
||||
await next()
|
||||
|
||||
if (c.res.status === httpStatus.OK) {
|
||||
const clonedRes = c.res.clone()
|
||||
clonedRes.headers.set('Cloudflare-CDN-Cache-Control', `max-age=${ttl}`)
|
||||
c.executionCtx.waitUntil(cache.put(reqMatcher, clonedRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Caches using default CF Cache behavior */
|
||||
export function withCacheDefault<T extends HonoApp>(ttl: number) {
|
||||
return async (ctx: Context<T>, next: Next): Promise<Response | void> => {
|
||||
const c = ctx as unknown as Context<HonoApp>
|
||||
|
||||
const cache = await caches.open('default')
|
||||
const cachedRes = await cache.match(c.req.raw)
|
||||
if (cachedRes) {
|
||||
return c.newResponse(cachedRes.body, cachedRes)
|
||||
}
|
||||
await next()
|
||||
|
||||
if (c.res.status === httpStatus.OK) {
|
||||
const clonedRes = c.res.clone()
|
||||
clonedRes.headers.set('Cloudflare-CDN-Cache-Control', `max-age=${ttl}`)
|
||||
c.executionCtx.waitUntil(cache.put(c.req.raw, clonedRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CacheByStatus {
|
||||
status: StatusCode
|
||||
/** Time in milliseconds to cache this status */
|
||||
ttl: number
|
||||
}
|
||||
|
||||
interface WithCacheByStatusOptions {
|
||||
rules: CacheByStatus[]
|
||||
/** Force caching rather than using default CF cache behavior */
|
||||
force: boolean
|
||||
}
|
||||
/** Caches responses based on status */
|
||||
export function withCacheByStatus<T extends HonoApp>(options: WithCacheByStatusOptions) {
|
||||
return async (ctx: Context<T>, next: Next): Promise<Response | void> => {
|
||||
const c = ctx as unknown as Context<HonoApp>
|
||||
|
||||
const cache = await caches.open('default')
|
||||
const reqMatcher = options.force ? new Request(c.req.url, { method: c.req.method }) : c.req.raw
|
||||
const cachedRes = await cache.match(reqMatcher)
|
||||
if (cachedRes) {
|
||||
return c.newResponse(cachedRes.body, cachedRes)
|
||||
}
|
||||
await next()
|
||||
const opts = options.rules.find((o) => o.status === c.res.status)
|
||||
if (opts) {
|
||||
const clonedRes = c.res.clone()
|
||||
clonedRes.headers.set('Cloudflare-CDN-Cache-Control', `max-age=${opts.ttl}`)
|
||||
c.executionCtx.waitUntil(cache.put(reqMatcher, clonedRes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { cors } from 'hono/cors'
|
||||
|
||||
/** Default CORS handler */
|
||||
export function withDefaultCors() {
|
||||
return cors({
|
||||
origin: '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'],
|
||||
allowHeaders: ['Content-Type', 'Authorization'],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { httpStatus } from 'http-codex/status'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { APIError } from '../helpers/errors'
|
||||
import type { HonoApp } from '../types'
|
||||
|
||||
/** Handles typical notFound hooks */
|
||||
export function withNotFound<T extends HonoApp>() {
|
||||
return async (ctx: Context<T>): Promise<Response> => {
|
||||
const c = ctx as unknown as Context<HonoApp>
|
||||
|
||||
return c.json(notFoundResponse, httpStatus.NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
export const notFoundResponse = {
|
||||
success: false,
|
||||
error: { message: 'not found' },
|
||||
} satisfies APIError
|
||||
@@ -0,0 +1,40 @@
|
||||
import { HTTPException } from 'hono/http-exception'
|
||||
import { httpStatus } from 'http-codex/status'
|
||||
|
||||
import { logger } from '../helpers/logger'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import type { APIError } from '../helpers/errors'
|
||||
import type { HonoApp } from '../types'
|
||||
|
||||
/** Handles typical onError hooks */
|
||||
export function withOnError<T extends HonoApp>() {
|
||||
return async (err: Error, ctx: Context<T>): Promise<Response> => {
|
||||
const c = ctx as unknown as Context<HonoApp>
|
||||
|
||||
if (err instanceof HTTPException) {
|
||||
const status = err.getResponse().status as ContentfulStatusCode
|
||||
const body: APIError = { success: false, error: { message: err.message } }
|
||||
if (status >= 500) {
|
||||
// TODO: Capture to Sentry
|
||||
// Log to Sentry
|
||||
logger.error(err)
|
||||
} else if (status === httpStatus.Unauthorized) {
|
||||
body.error.message = 'unauthorized'
|
||||
}
|
||||
|
||||
return c.json(body, status)
|
||||
}
|
||||
|
||||
// TODO: Capture to Sentry
|
||||
logger.error(err)
|
||||
return c.json(
|
||||
{
|
||||
success: false,
|
||||
error: { message: 'internal server error' },
|
||||
} satisfies APIError,
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type WorkersEnvironment = z.infer<typeof WorkersEnvironment>
|
||||
export const WorkersEnvironment = z.enum(['VITEST', 'development', 'staging', 'production'])
|
||||
|
||||
/** Global bindings */
|
||||
export type SharedHonoEnv = {
|
||||
/**
|
||||
* Name of the worker used in logging/etc.
|
||||
* Automatically pulled from package.json
|
||||
*/
|
||||
NAME: string
|
||||
/**
|
||||
* Environment of the worker.
|
||||
* All workers should specify env in wrangler.jsonc vars
|
||||
*/
|
||||
ENVIRONMENT: WorkersEnvironment
|
||||
/**
|
||||
* Release version of the Worker (based on the current git commit).
|
||||
* Useful for logs, Sentry, etc.
|
||||
*/
|
||||
SENTRY_RELEASE: string
|
||||
}
|
||||
/** Global Hono variables */
|
||||
export type SharedHonoVariables = {
|
||||
// Things like Sentry, etc. that should be present on all Workers
|
||||
}
|
||||
|
||||
/** Top-level Hono app */
|
||||
export interface HonoApp {
|
||||
Variables: SharedHonoVariables
|
||||
Bindings: SharedHonoEnv
|
||||
}
|
||||
|
||||
/** Context used for non-Hono things like Durable Objects */
|
||||
export type SharedAppContext = {
|
||||
var: SharedHonoVariables
|
||||
env: SharedHonoEnv
|
||||
executionCtx: Pick<ExecutionContext, 'waitUntil'>
|
||||
}
|
||||
Reference in New Issue
Block a user