Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/adapter/cloudflare-workers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
109 changes: 109 additions & 0 deletions src/jsx/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,67 @@ describe('ErrorBoundary', () => {
errorBoundaryCounter--
suspenseCounter--
})

it('string content', async () => {
const html = <ErrorBoundary fallback={<Fallback />}>{'< ok >'}</ErrorBoundary>

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; ok &gt;')

errorBoundaryCounter--
suspenseCounter--
})

it('error: string content', async () => {
const html = (
<ErrorBoundary fallback={'< error >'}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

errorBoundaryCounter--
suspenseCounter--
})

it('error: Promise<string> from fallback', async () => {
const html = (
<ErrorBoundary fallback={Promise.resolve('< error >')}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

errorBoundaryCounter--
suspenseCounter--
})

it('error: string content from fallbackRender', async () => {
const html = (
<ErrorBoundary fallbackRender={() => '< error >'}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

errorBoundaryCounter--
suspenseCounter--
})

it('error: Promise<string> from fallbackRender', async () => {
const html = (
<ErrorBoundary fallbackRender={() => Promise.resolve('< error >')}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

errorBoundaryCounter--
suspenseCounter--
})
})

describe('async', async () => {
Expand Down Expand Up @@ -123,6 +184,54 @@ describe('ErrorBoundary', () => {

suspenseCounter--
})

it('error: string content', async () => {
const html = (
<ErrorBoundary fallback={'< error >'}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

suspenseCounter--
})

it('error: Promise<string> from fallback', async () => {
const html = (
<ErrorBoundary fallback={Promise.resolve('< error >')}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

suspenseCounter--
})

it('error: string content from fallbackRender', async () => {
const html = (
<ErrorBoundary fallbackRender={() => '< error >'}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

suspenseCounter--
})

it('error: Promise<string> from fallbackRender', async () => {
const html = (
<ErrorBoundary fallbackRender={() => Promise.resolve('< error >')}>
<Component error={true} />
</ErrorBoundary>
)

expect((await resolveCallback(await html.toString())).toString()).toEqual('&lt; error &gt;')

suspenseCounter--
})
})

describe('async : nested', async () => {
Expand Down
52 changes: 41 additions & 11 deletions src/jsx/components.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,6 +26,21 @@ export const childrenToString = async (children: Child[]): Promise<HtmlEscapedSt
}
}

const resolveChildEarly = (c: Child): HtmlEscapedString | Promise<HtmlEscapedString> => {
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<HtmlEscapedString>
}
}
}

export type ErrorHandler = (error: Error) => void
export type FallbackRender = (error: Error) => Child

Expand All @@ -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<HtmlEscapedString> => {
onError?.(error)
return (fallbackStr || fallbackRender?.(error) || '').toString() as HtmlEscapedString
return (fallbackStr ||
(fallbackRender && jsx(Fragment, {}, fallbackRender(error) as HtmlEscapedString)) ||
'') as HtmlEscapedString
}
let resArray: HtmlEscapedString[] | Promise<HtmlEscapedString[]>[] = []
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<HtmlEscapedString[]>[]
} 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(`(<template id="E:${index}"></template>.*?)(.*?)(<!--E:${index}-->)`)
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)
}
Expand Down Expand Up @@ -195,7 +225,7 @@ d.remove()
},
])
} else {
return raw(resArray.join(''))
return Fragment({ children: resArray as Child[] })
}
}
;(ErrorBoundary as HasRenderToDom)[DOM_RENDERER] = ErrorBoundaryDomRenderer
83 changes: 83 additions & 0 deletions src/middleware/cache/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,86 @@ describe('Cache Middleware', () => {
expect(res.headers.get('cache-control')).toBe(null)
})
})

describe('Cache Skipping Logic', () => {
let putSpy: ReturnType<typeof vi.fn>

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()
})
})
21 changes: 17 additions & 4 deletions src/middleware/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,23 @@ const defaultCacheableStatusCodes: ReadonlyArray<StatusCode> = [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
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/utils/ipaddr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/utils/ipaddr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading