diff --git a/src/adapter/cloudflare-workers/utils.ts b/src/adapter/cloudflare-workers/utils.ts
index af7cf92a6..74f7114c8 100644
--- a/src/adapter/cloudflare-workers/utils.ts
+++ b/src/adapter/cloudflare-workers/utils.ts
@@ -36,7 +36,7 @@ export const getContentFromKVAsset = async (
ASSET_NAMESPACE = __STATIC_CONTENT
}
- const key = ASSET_MANIFEST[path] || path
+ const key = ASSET_MANIFEST[path]
if (!key) {
return null
}
diff --git a/src/jsx/components.test.tsx b/src/jsx/components.test.tsx
index 690d597cb..0da31de1f 100644
--- a/src/jsx/components.test.tsx
+++ b/src/jsx/components.test.tsx
@@ -86,6 +86,67 @@ describe('ErrorBoundary', () => {
errorBoundaryCounter--
suspenseCounter--
})
+
+ it('string content', async () => {
+ const html = }>{'< ok >'}
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< ok >')
+
+ errorBoundaryCounter--
+ suspenseCounter--
+ })
+
+ it('error: string content', async () => {
+ const html = (
+ '}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ errorBoundaryCounter--
+ suspenseCounter--
+ })
+
+ it('error: Promise from fallback', async () => {
+ const html = (
+ ')}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ errorBoundaryCounter--
+ suspenseCounter--
+ })
+
+ it('error: string content from fallbackRender', async () => {
+ const html = (
+ '< error >'}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ errorBoundaryCounter--
+ suspenseCounter--
+ })
+
+ it('error: Promise from fallbackRender', async () => {
+ const html = (
+ Promise.resolve('< error >')}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ errorBoundaryCounter--
+ suspenseCounter--
+ })
})
describe('async', async () => {
@@ -123,6 +184,54 @@ describe('ErrorBoundary', () => {
suspenseCounter--
})
+
+ it('error: string content', async () => {
+ const html = (
+ '}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ suspenseCounter--
+ })
+
+ it('error: Promise from fallback', async () => {
+ const html = (
+ ')}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ suspenseCounter--
+ })
+
+ it('error: string content from fallbackRender', async () => {
+ const html = (
+ '< error >'}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ suspenseCounter--
+ })
+
+ it('error: Promise from fallbackRender', async () => {
+ const html = (
+ Promise.resolve('< error >')}>
+
+
+ )
+
+ expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >')
+
+ suspenseCounter--
+ })
})
describe('async : nested', async () => {
diff --git a/src/jsx/components.ts b/src/jsx/components.ts
index f8cc87226..bec77303c 100644
--- a/src/jsx/components.ts
+++ b/src/jsx/components.ts
@@ -1,6 +1,7 @@
import { raw } from '../helper/html'
import type { HtmlEscapedCallback, HtmlEscapedString } from '../utils/html'
import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html'
+import { jsx, Fragment } from './base'
import { DOM_RENDERER } from './constants'
import { useContext } from './context'
import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components'
@@ -25,6 +26,21 @@ export const childrenToString = async (children: Child[]): Promise => {
+ if (c == null || typeof c === 'boolean') {
+ return '' as HtmlEscapedString
+ } else if (typeof c === 'string') {
+ return c as HtmlEscapedString
+ } else {
+ const str = c.toString()
+ if (!(str instanceof Promise)) {
+ return raw(str)
+ } else {
+ return str as Promise
+ }
+ }
+}
+
export type ErrorHandler = (error: Error) => void
export type FallbackRender = (error: Error) => Child
@@ -51,37 +67,51 @@ export const ErrorBoundary: FC<
const nonce = useContext(StreamingContext)?.scriptNonce
let fallbackStr: string | undefined
- const fallbackRes = (error: Error): HtmlEscapedString => {
+ const resolveFallbackStr = async () => {
+ const awaitedFallback = await fallback
+ if (typeof awaitedFallback === 'string') {
+ fallbackStr = awaitedFallback
+ } else {
+ fallbackStr = await awaitedFallback?.toString()
+ if (typeof fallbackStr === 'string') {
+ // should not apply `raw` if fallbackStr is undefined, null, or boolean
+ fallbackStr = raw(fallbackStr)
+ }
+ }
+ }
+ const fallbackRes = (error: Error): HtmlEscapedString | Promise => {
onError?.(error)
- return (fallbackStr || fallbackRender?.(error) || '').toString() as HtmlEscapedString
+ return (fallbackStr ||
+ (fallbackRender && jsx(Fragment, {}, fallbackRender(error) as HtmlEscapedString)) ||
+ '') as HtmlEscapedString
}
let resArray: HtmlEscapedString[] | Promise[] = []
try {
- resArray = children.map((c) =>
- c == null || typeof c === 'boolean' ? '' : c.toString()
- ) as HtmlEscapedString[]
+ resArray = children.map(resolveChildEarly) as unknown as HtmlEscapedString[]
} catch (e) {
- fallbackStr = await fallback?.toString()
+ await resolveFallbackStr()
if (e instanceof Promise) {
resArray = [
e.then(() => childrenToString(children as Child[])).catch((e) => fallbackRes(e)),
] as Promise[]
} else {
- resArray = [fallbackRes(e as Error)]
+ resArray = [fallbackRes(e as Error) as HtmlEscapedString]
}
}
if (resArray.some((res) => (res as {}) instanceof Promise)) {
- fallbackStr ||= await fallback?.toString()
+ await resolveFallbackStr()
const index = errorBoundaryCounter++
const replaceRe = RegExp(`(.*?)(.*?)()`)
const caught = false
- const catchCallback = ({ error, buffer }: { error: Error; buffer?: [string] }) => {
+ const catchCallback = async ({ error, buffer }: { error: Error; buffer?: [string] }) => {
if (caught) {
return ''
}
- const fallbackResString = fallbackRes(error)
+ const fallbackResString = await Fragment({
+ children: fallbackRes(error),
+ }).toString()
if (buffer) {
buffer[0] = buffer[0].replace(replaceRe, fallbackResString)
}
@@ -195,7 +225,7 @@ d.remove()
},
])
} else {
- return raw(resArray.join(''))
+ return Fragment({ children: resArray as Child[] })
}
}
;(ErrorBoundary as HasRenderToDom)[DOM_RENDERER] = ErrorBoundaryDomRenderer
diff --git a/src/middleware/cache/index.test.ts b/src/middleware/cache/index.test.ts
index a42e61ee4..31461af61 100644
--- a/src/middleware/cache/index.test.ts
+++ b/src/middleware/cache/index.test.ts
@@ -417,3 +417,86 @@ describe('Cache Middleware', () => {
expect(res.headers.get('cache-control')).toBe(null)
})
})
+
+describe('Cache Skipping Logic', () => {
+ let putSpy: ReturnType
+
+ beforeEach(() => {
+ putSpy = vi.fn()
+ const mockCache = {
+ match: vi.fn().mockResolvedValue(undefined), // Always miss
+ put: putSpy, // We spy on this
+ keys: vi.fn().mockResolvedValue([]),
+ }
+
+ vi.stubGlobal('caches', {
+ open: vi.fn().mockResolvedValue(mockCache),
+ })
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('Should NOT cache response if Cache-Control contains "private"', async () => {
+ const app = new Hono()
+ app.use('*', cache({ cacheName: 'skip-test', wait: true }))
+ app.get('/', (c) => {
+ c.header('Cache-Control', 'private, max-age=3600')
+ return c.text('response')
+ })
+
+ const res = await app.request('/')
+ expect(res.status).toBe(200)
+ // IMPORTANT: put() should NOT be called
+ expect(putSpy).not.toHaveBeenCalled()
+ })
+
+ it('Should NOT cache response if Cache-Control contains "no-store"', async () => {
+ const app = new Hono()
+ app.use('*', cache({ cacheName: 'skip-test', wait: true }))
+ app.get('/', (c) => {
+ c.header('Cache-Control', 'no-store')
+ return c.text('response')
+ })
+
+ await app.request('/')
+ expect(putSpy).not.toHaveBeenCalled()
+ })
+
+ it('Should NOT cache response if Cache-Control contains no-cache="Set-Cookie"', async () => {
+ const app = new Hono()
+ app.use('*', cache({ cacheName: 'skip-test', wait: true }))
+ app.get('/', (c) => {
+ c.header('Cache-Control', 'no-cache="Set-Cookie"')
+ return c.text('response')
+ })
+
+ await app.request('/')
+ expect(putSpy).not.toHaveBeenCalled()
+ })
+
+ it('Should NOT cache response if Set-Cookie header is present', async () => {
+ const app = new Hono()
+ app.use('*', cache({ cacheName: 'skip-test', wait: true }))
+ app.get('/', (c) => {
+ c.header('Set-Cookie', 'session=secret')
+ return c.text('response')
+ })
+
+ await app.request('/')
+ expect(putSpy).not.toHaveBeenCalled()
+ })
+
+ it('Should cache normal responses (Control Test)', async () => {
+ const app = new Hono()
+ app.use('*', cache({ cacheName: 'skip-test', wait: true }))
+ app.get('/', (c) => {
+ return c.text('response')
+ })
+
+ await app.request('/')
+ // IMPORTANT: put() SHOULD be called for normal responses
+ expect(putSpy).toHaveBeenCalled()
+ })
+})
diff --git a/src/middleware/cache/index.ts b/src/middleware/cache/index.ts
index c6ec9484a..1f4c311a3 100644
--- a/src/middleware/cache/index.ts
+++ b/src/middleware/cache/index.ts
@@ -14,10 +14,23 @@ const defaultCacheableStatusCodes: ReadonlyArray = [200]
const shouldSkipCache = (res: Response) => {
const vary = res.headers.get('Vary')
- // Don't cache for Vary: *
- // https://www.rfc-editor.org/rfc/rfc9111#section-4.1
- // Also note that some runtimes throw a TypeError for it.
- return vary && vary.includes('*')
+ if (vary && vary.includes('*')) {
+ return true
+ }
+
+ const cacheControl = res.headers.get('Cache-Control')
+ if (
+ cacheControl &&
+ /(?:^|,\s*)(?:private|no-(?:store|cache))(?:\s*(?:=|,|$))/i.test(cacheControl)
+ ) {
+ return true
+ }
+
+ if (res.headers.has('Set-Cookie')) {
+ return true
+ }
+
+ return false
}
/**
diff --git a/src/utils/ipaddr.test.ts b/src/utils/ipaddr.test.ts
index be76efe14..9636be5ff 100644
--- a/src/utils/ipaddr.test.ts
+++ b/src/utils/ipaddr.test.ts
@@ -28,6 +28,20 @@ describe('distinctRemoteAddr', () => {
expect(distinctRemoteAddr('example.com')).toBeUndefined()
})
+
+ it('Should reject invalid IPv4 addresses with octets > 255', () => {
+ expect(distinctRemoteAddr('1.2.3.256')).toBeUndefined()
+ expect(distinctRemoteAddr('1.2.3.999')).toBeUndefined()
+ expect(distinctRemoteAddr('1.2.2.355')).toBeUndefined()
+ expect(distinctRemoteAddr('256.0.0.1')).toBeUndefined()
+ expect(distinctRemoteAddr('999.999.999.999')).toBeUndefined()
+ })
+
+ it('Should accept valid IPv4 edge cases', () => {
+ expect(distinctRemoteAddr('0.0.0.0')).toBe('IPv4')
+ expect(distinctRemoteAddr('255.255.255.255')).toBe('IPv4')
+ expect(distinctRemoteAddr('1.2.3.4')).toBe('IPv4')
+ })
})
describe('convertIPv4ToBinary', () => {
diff --git a/src/utils/ipaddr.ts b/src/utils/ipaddr.ts
index 1ad47c2bd..aa67719ca 100644
--- a/src/utils/ipaddr.ts
+++ b/src/utils/ipaddr.ts
@@ -33,7 +33,8 @@ export const expandIPv6 = (ipV6: string): string => {
return sections.join(':')
}
-const IPV4_REGEX = /^[0-9]{0,3}\.[0-9]{0,3}\.[0-9]{0,3}\.[0-9]{0,3}$/
+const IPV4_OCTET_PART = '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])'
+const IPV4_REGEX = new RegExp(`^(?:${IPV4_OCTET_PART}\\.){3}${IPV4_OCTET_PART}$`)
/**
* Distinct Remote Addr