Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(serve-static): "206 Partial Content" support #3461

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
71 changes: 71 additions & 0 deletions runtime-tests/bun/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,77 @@ describe('Serve Static Middleware', () => {
expect(await res.text()).toBe('Bun!')
expect(onNotFound).not.toHaveBeenCalled()
})

describe('Range Request', () => {
it('Should support a single range request', async () => {
const res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=0-4' } })
)
expect(await res.text()).toBe('\u0000\u0000\u0001\u0000\u0003')
expect(res.status).toBe(206)
expect(res.headers.get('Content-Type')).toBe('image/x-icon')
expect(res.headers.get('Content-Length')).toBe('5')
expect(res.headers.get('Content-Range')).toBe('bytes 0-4/15406')
})

it('Should support a single range where its end is larger than the actual size', async () => {
const res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-20000' } })
)
expect(await res.text()).toBe('\u0000\u0000\u0000\u0000\u0000\u0000')
expect(res.status).toBe(206)
expect(res.headers.get('Content-Type')).toBe('image/x-icon')
expect(res.headers.get('Content-Length')).toBe('6')
expect(res.headers.get('Content-Range')).toBe('bytes 15400-15405/15406')
})

it('Should support omitted end', async () => {
const res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-' } })
)
expect(await res.text()).toBe('\u0000\u0000\u0000\u0000\u0000\u0000')
expect(res.status).toBe(206)
expect(res.headers.get('Content-Type')).toBe('image/x-icon')
expect(res.headers.get('Content-Length')).toBe('6')
expect(res.headers.get('Content-Range')).toBe('bytes 15400-15405/15406')
})

it('Should support the last N bytes request', async () => {
const res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=-10' } })
)
expect(await res.text()).toBe('\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000')
expect(res.status).toBe(206)
expect(res.headers.get('Content-Type')).toBe('image/x-icon')
expect(res.headers.get('Content-Length')).toBe('10')
expect(res.headers.get('Content-Range')).toBe('bytes 15396-15405/15406')
})

it('Should support multiple ranges', async () => {
const res = await app.request(
new Request('http://localhost/favicon.ico', {
headers: { Range: 'bytes=151-200,351-400,15401-15500' },
})
)
await res.arrayBuffer()
expect(res.status).toBe(206)
expect(res.headers.get('Content-Type')).toBe(
'multipart/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY'
)
expect(res.headers.get('Content-Length')).toBe('105')
expect(res.headers.get('Content-Range')).toBeNull()
})

it('Should return 404 if file no found', async () => {
const res = await app.request(
new Request('http://localhost/static/no-such-image.png', {
headers: { Range: 'bytes=151-200,351-400,15401-15500' },
})
)
await res.arrayBuffer()
expect(res.status).toBe(404)
})
})
})

// Bun support WebCrypto since v0.2.2
Expand Down
64 changes: 64 additions & 0 deletions runtime-tests/deno/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,70 @@ Deno.test('Serve Static middleware', async () => {
res = await app.request('http://localhost/static-absolute-root/plain.txt')
assertEquals(res.status, 200)
assertEquals(await res.text(), 'Deno!')

// Should support a single range request
res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=0-4' } })
)
assertEquals(await res.text(), '\u0000\u0000\u0001\u0000\u0003')
assertEquals(res.status, 206)
assertEquals(res.headers.get('Content-Type'), 'image/x-icon')
assertEquals(res.headers.get('Content-Length'), '5')
assertEquals(res.headers.get('Content-Range'), 'bytes 0-4/15406')

// Should support a single range where its end is larger than the actual size
res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-20000' } })
)
assertEquals(await res.text(), '\u0000\u0000\u0000\u0000\u0000\u0000')
assertEquals(res.status, 206)
assertEquals(res.headers.get('Content-Type'), 'image/x-icon')
assertEquals(res.headers.get('Content-Length'), '6')
assertEquals(res.headers.get('Content-Range'), 'bytes 15400-15405/15406')

// Should support omitted end
res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-' } })
)
assertEquals(await res.text(), '\u0000\u0000\u0000\u0000\u0000\u0000')
assertEquals(res.status, 206)
assertEquals(res.headers.get('Content-Type'), 'image/x-icon')
assertEquals(res.headers.get('Content-Length'), '6')
assertEquals(res.headers.get('Content-Range'), 'bytes 15400-15405/15406')

// Should support the last N bytes request
res = await app.request(
new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=-10' } })
)
assertEquals(await res.text(), '\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000')
assertEquals(res.status, 206)
assertEquals(res.headers.get('Content-Type'), 'image/x-icon')
assertEquals(res.headers.get('Content-Length'), '10')
assertEquals(res.headers.get('Content-Range'), 'bytes 15396-15405/15406')

// Should support multiple ranges
res = await app.request(
new Request('http://localhost/favicon.ico', {
headers: { Range: 'bytes=151-200,351-400,15401-15500' },
})
)
await res.arrayBuffer()
assertEquals(res.status, 206)
assertEquals(
res.headers.get('Content-Type'),
'multipart/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY'
)
assertEquals(res.headers.get('Content-Length'), '105')
assertEquals(res.headers.get('Content-Range'), null)

// Should return 404 if file no found'
res = await app.request(
new Request('http://localhost/static/no-such-image.png', {
headers: { Range: 'bytes=151-200,351-400,15401-15500' },
})
)
await res.arrayBuffer()
assertEquals(res.status, 404)
})

Deno.test('JWT Authentication middleware', async () => {
Expand Down
34 changes: 33 additions & 1 deletion src/adapter/bun/serve-static.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { stat } from 'node:fs/promises'
import { open, stat } from 'node:fs/promises'
import { serveStatic as baseServeStatic } from '../../middleware/serve-static'
import type { ServeStaticOptions } from '../../middleware/serve-static'
import type { Env, MiddlewareHandler } from '../../types'
Expand Down Expand Up @@ -30,6 +30,38 @@
getContent,
pathResolve,
isDir,
partialContentSupport: async (path: string) => {
path = path.startsWith('/') ? path : `./${path}`
const handle = await open(path)
const size = (await handle.stat()).size
return {
size,
getPartialContent: function getPartialContent(start: number, end: number) {
const readStream = handle.createReadStream({ start, end })
const data = new ReadableStream({
start(controller) {
readStream.on('data', (chunk) => {
controller.enqueue(chunk)
})
readStream.on('end', () => {
controller.close()
})
readStream.on('error', (e) => {
controller.error(e)

Check warning on line 50 in src/adapter/bun/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L49-L50

Added lines #L49 - L50 were not covered by tests
})
},

Check warning on line 52 in src/adapter/bun/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L52

Added line #L52 was not covered by tests
})
return {
start,
end,
data,
}
},

Check warning on line 59 in src/adapter/bun/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L59

Added line #L59 was not covered by tests
close: () => {
handle.close()
},

Check warning on line 62 in src/adapter/bun/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L62

Added line #L62 was not covered by tests
}
},

Check warning on line 64 in src/adapter/bun/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L64

Added line #L64 was not covered by tests
})(c, next)
}
}
1 change: 1 addition & 0 deletions src/adapter/cloudflare-workers/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const serveStatic = <E extends Env = Env>(
: undefined,
})
}
// partialContentSupport is not implemented since this middleware is deprecated
return baseServeStatic({
...options,
getContent,
Expand Down
20 changes: 20 additions & 0 deletions src/adapter/deno/deno.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ declare namespace Deno {
*/
export function mkdir(path: string, options?: { recursive?: boolean }): Promise<void>

export function lstatSync(path: string): {
isDirectory: boolean
size: number
}

/**
* Write a new file, with the specified path and data.
*
Expand All @@ -25,4 +30,19 @@ declare namespace Deno {
response: Response
socket: WebSocket
}

export function open(path: string): Promise<FsFile>

export enum SeekMode {
Start = 0,
}

export function seekSync(rid: number, offset: number, whence: SeekMode): number
export function readSync(rid: number, buffer: Uint8Array): number

export type FsFile = {
rid: number
readable: ReadableStream<Uint8Array>
close(): void
}
}
29 changes: 24 additions & 5 deletions src/adapter/deno/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
import { serveStatic as baseServeStatic } from '../../middleware/serve-static'
import type { Env, MiddlewareHandler } from '../../types'

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { open, lstatSync } = Deno
const { open, lstatSync, seekSync, readSync, SeekMode } = Deno

Check warning on line 5 in src/adapter/deno/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/deno/serve-static.ts#L5

Added line #L5 was not covered by tests

export const serveStatic = <E extends Env = Env>(
options: ServeStaticOptions<E>
Expand All @@ -13,10 +11,10 @@
const getContent = async (path: string) => {
try {
const file = await open(path)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return file ? (file.readable as any) : null
return file?.readable ?? null

Check warning on line 14 in src/adapter/deno/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/deno/serve-static.ts#L14

Added line #L14 was not covered by tests
} catch (e) {
console.warn(`${e}`)
return null

Check warning on line 17 in src/adapter/deno/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/deno/serve-static.ts#L17

Added line #L17 was not covered by tests
}
}
const pathResolve = (path: string) => {
Expand All @@ -35,6 +33,27 @@
getContent,
pathResolve,
isDir,
partialContentSupport: async (path: string) => {
path = path.startsWith('/') ? path : `./${path}`
const handle = await open(path)
const size = lstatSync(path).size
return {
size,
getPartialContent: function getPartialContent(start: number, end: number) {
seekSync(handle.rid, start, SeekMode.Start)
const data = new Uint8Array(end - start + 1)
readSync(handle.rid, data)
return {
start,
end,
data,
}
},
close: () => {
handle.close()
},
}
},

Check warning on line 56 in src/adapter/deno/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/deno/serve-static.ts#L36-L56

Added lines #L36 - L56 were not covered by tests
})(c, next)
}
}
Loading