Initial commit

This commit is contained in:
Devin Zuczek
2026-06-08 18:23:40 -04:00
commit 8e644a5c20
141 changed files with 21414 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# hono-helpers
A package with shared helpers for Hono applications
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@repo/hono-helpers",
"version": "0.1.4",
"private": true,
"sideEffects": false,
"type": "module",
"main": "src/index.ts",
"scripts": {
"check:lint": "run-oxlint",
"check:types": "run-tsc",
"test": "run-vitest"
},
"dependencies": {
"@hono/standard-validator": "0.2.2",
"hono": "4.12.9",
"http-codex": "0.6.6",
"workers-tagged-logger": "1.0.0",
"zod": "4.3.6"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "0.13.3",
"@cloudflare/workers-types": "4.20260317.1",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*",
"vitest": "4.1.0"
}
}
@@ -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])
)
)
}
+29
View File
@@ -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
}
+9
View File
@@ -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
)
}
}
+40
View File
@@ -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'>
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@repo/typescript-config/workers-lib.json"
}