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