Skip to content
Open
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
64 changes: 41 additions & 23 deletions src/middleware/etag/digest.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,56 @@
const mergeBuffers = (buffer1: ArrayBuffer | undefined, buffer2: Uint8Array): Uint8Array => {
if (!buffer1) {
return buffer2
}
const merged = new Uint8Array(buffer1.byteLength + buffer2.byteLength)
merged.set(new Uint8Array(buffer1), 0)
merged.set(buffer2, buffer1.byteLength)
return merged
}

export const generateDigest = async (
stream: ReadableStream<Uint8Array> | null,
input: ReadableStream<Uint8Array> | ArrayBuffer | null,
generator: (body: Uint8Array) => ArrayBuffer | Promise<ArrayBuffer>
): Promise<string | null> => {
if (!stream) {
if (!input) {
return null
}

let result: ArrayBuffer | undefined = undefined

const reader = stream.getReader()
for (;;) {
const { value, done } = await reader.read()
if (done) {
break
}
if (input instanceof ArrayBuffer) {
result = await generator(new Uint8Array(input))
} else {
const chunks: Uint8Array[] = []
let totalLength = 0
const reader = input.getReader()

try {
while (true) {
const { value, done } = await reader.read()
if (done) break

result = await generator(mergeBuffers(result, value))
if (value.length > 0) {
chunks.push(value)
totalLength += value.length
}
}

if (totalLength === 0) {
return null
}

const accumulated = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
accumulated.set(chunk, offset)
offset += chunk.length
}

result = await generator(accumulated)
} finally {
reader.releaseLock()
}
}

if (!result) {
if (!result || result.byteLength === 0) {
return null
}

return Array.prototype.map
.call(new Uint8Array(result), (x) => x.toString(16).padStart(2, '0'))
.join('')
const bytes = new Uint8Array(result)
const hex = new Array(bytes.length)
for (let i = 0; i < bytes.length; i++) {
hex[i] = bytes[i].toString(16).padStart(2, '0')
}
return hex.join('')
}
253 changes: 253 additions & 0 deletions src/middleware/etag/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,5 +293,258 @@ describe('Etag Middleware', () => {
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBeNull()
})

it('Should not generate etag with custom generator when crypto is unavailable', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: undefined,
})
)
app.get('/etag/no-custom-digest', (c) => c.text('Hono is hot'))
const res = await app.request('/etag/no-custom-digest')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBeNull()
})
})

describe('AWS Lambda environment', () => {
let originalEnv: NodeJS.ProcessEnv

beforeAll(() => {
originalEnv = { ...process.env }
})

afterAll(() => {
process.env = originalEnv
})

it('Should handle etag generation in Lambda environment with AWS_LAMBDA_FUNCTION_NAME', async () => {
process.env.AWS_LAMBDA_FUNCTION_NAME = 'test-function'
delete process.env.AWS_EXECUTION_ENV
delete process.env.LAMBDA_TASK_ROOT

const app = new Hono()
app.use('/etag/*', etag())
app.get('/etag/lambda', (c) => {
return c.text('Hono in Lambda')
})

const res = await app.request('http://localhost/etag/lambda')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).not.toBeFalsy()
expect(await res.text()).toBe('Hono in Lambda')
})

it('Should handle etag generation in Lambda environment with AWS_EXECUTION_ENV', async () => {
delete process.env.AWS_LAMBDA_FUNCTION_NAME
process.env.AWS_EXECUTION_ENV = 'AWS_Lambda_nodejs18.x'
delete process.env.LAMBDA_TASK_ROOT

const app = new Hono()
app.use('/etag/*', etag())
app.get('/etag/lambda-exec', (c) => {
return c.text('Lambda execution env')
})

const res = await app.request('http://localhost/etag/lambda-exec')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).not.toBeFalsy()
expect(await res.text()).toBe('Lambda execution env')
})

it('Should handle etag generation in Lambda environment with LAMBDA_TASK_ROOT', async () => {
delete process.env.AWS_LAMBDA_FUNCTION_NAME
delete process.env.AWS_EXECUTION_ENV
process.env.LAMBDA_TASK_ROOT = '/var/task'

const app = new Hono()
app.use('/etag/*', etag())
app.get('/etag/lambda-task', (c) => {
return c.json({ message: 'Lambda task root', data: [1, 2, 3] })
})

const res = await app.request('http://localhost/etag/lambda-task')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).not.toBeFalsy()
const jsonData = await res.json()
expect(jsonData.message).toBe('Lambda task root')
expect(jsonData.data).toEqual([1, 2, 3])
})
})

describe('Edge cases and additional coverage', () => {
it('Should handle ReadableStream with mixed empty and non-empty chunks', async () => {
const app = new Hono()
app.use('/etag/*', etag())
app.get('/etag/mixed-chunks', (c) => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(0)) // Empty chunk
controller.enqueue(new Uint8Array([0xaa, 0xbb])) // Non-empty chunk
controller.enqueue(new Uint8Array(0)) // Empty chunk
controller.enqueue(new Uint8Array([0xcc])) // Non-empty chunk
controller.close()
},
})
return c.body(stream)
})

const res = await app.request('http://localhost/etag/mixed-chunks')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).not.toBeFalsy()
expect(res.headers.get('ETag')).toMatch(/^"[a-f0-9]+"$/)
})

it('Should propagate generateDigest errors', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: () => {
throw new Error('Digest generation failed')
},
})
)
app.get('/etag/error-digest', (c) => c.text('Error test'))

const res = await app.request('http://localhost/etag/error-digest')
expect(res.status).toBe(500)
})
})

describe('Digest generation edge cases', () => {
it('Should handle ArrayBuffer input directly', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: (body: Uint8Array) => {
const result = new ArrayBuffer(2)
const view = new Uint8Array(result)
view[0] = body[0] || 0xaa
view[1] = body[1] || 0xbb
return result
},
})
)
app.get('/etag/array-buffer', (c) => {
const buffer = new ArrayBuffer(4)
const view = new Uint8Array(buffer)
view[0] = 0x12
view[1] = 0x34
view[2] = 0x56
view[3] = 0x78
return c.body(buffer)
})

const res = await app.request('http://localhost/etag/array-buffer')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBe('"1234"')
})

it('Should handle generator returning empty ArrayBuffer', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: () => new ArrayBuffer(0),
})
)
app.get('/etag/empty-result', (c) => c.text('Test'))

const res = await app.request('http://localhost/etag/empty-result')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBeNull()
})

it('Should handle async generator with ArrayBuffer input (covers line 13-14)', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: async (body: Uint8Array) => {
// This async function will trigger the await on line 13-14 in digest.ts
return new Promise((resolve) => {
setTimeout(() => {
const result = new ArrayBuffer(2)
const view = new Uint8Array(result)
view[0] = body[0] || 0xaa
view[1] = body[1] || 0xbb
resolve(result)
}, 0)
})
},
})
)
app.get('/etag/async-arraybuffer', (c) => {
// Use ArrayBuffer to trigger the ArrayBuffer branch in digest.ts
const buffer = new ArrayBuffer(2)
const view = new Uint8Array(buffer)
view[0] = 0x12
view[1] = 0x34
return c.body(buffer)
})

const res = await app.request('http://localhost/etag/async-arraybuffer')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBe('"1234"')
})

it('Should handle async generator returning empty ArrayBuffer with ArrayBuffer input', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: async () => {
// Async generator that returns empty ArrayBuffer - covers line 50-51
return new Promise((resolve) => {
setTimeout(() => {
resolve(new ArrayBuffer(0))
}, 0)
})
},
})
)
app.get('/etag/async-empty-arraybuffer', (c) => {
const buffer = new ArrayBuffer(1)
const view = new Uint8Array(buffer)
view[0] = 0xaa
return c.body(buffer)
})

const res = await app.request('http://localhost/etag/async-empty-arraybuffer')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBeNull()
})

it('Should handle hex conversion for all byte values', async () => {
const app = new Hono()
app.use(
'/etag/*',
etag({
generateDigest: (body: Uint8Array) => {
const result = new ArrayBuffer(body.length)
const view = new Uint8Array(result)
view.set(body)
return result
},
})
)
app.get('/etag/hex-test', (c) => {
const buffer = new ArrayBuffer(4)
const view = new Uint8Array(buffer)
view[0] = 0x00
view[1] = 0x0f
view[2] = 0xa0
view[3] = 0xff
return c.body(buffer)
})

const res = await app.request('http://localhost/etag/hex-test')
expect(res.status).toBe(200)
expect(res.headers.get('ETag')).toBe('"000fa0ff"')
})
})
})
21 changes: 20 additions & 1 deletion src/middleware/etag/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,26 @@ export const etag = (options?: ETagOptions): MiddlewareHandler => {
if (!generator) {
return
}
const hash = await generateDigest(res.clone().body, generator)

const isLambda = !!(
process.env.AWS_EXECUTION_ENV ||
process.env.LAMBDA_TASK_ROOT ||
process.env.AWS_LAMBDA_FUNCTION_NAME
)
let hash: string | null = null

if (isLambda) {
const buffer = await res.arrayBuffer()
hash = await generateDigest(buffer, generator)
c.res = new Response(buffer, {
status: res.status,
statusText: res.statusText,
headers: new Headers(res.headers),
})
} else {
hash = await generateDigest(res.clone().body, generator)
}

if (hash === null) {
return
}
Expand Down
Loading