Skip to content

Commit

Permalink
feat(serve-static): adapter
Browse files Browse the repository at this point in the history
  • Loading branch information
exoego committed Sep 30, 2024
1 parent 8b92549 commit d29d1e9
Show file tree
Hide file tree
Showing 7 changed files with 150 additions and 9 deletions.
62 changes: 62 additions & 0 deletions runtime-tests/bun/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,68 @@ describe('Serve Static Middleware', () => {
expect(res.headers.get('Content-Type')).toBe('image/x-icon')
})

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')
// expect(await res.text()).tobe('aaa')
})

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 response', async () => {
const res = await app.request(new Request('http://localhost/favicon-notfound.ico'))
expect(res.status).toBe(404)
Expand Down
2 changes: 2 additions & 0 deletions runtime-tests/deno/middleware.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ 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!')

// TODO: Test range request
})

Deno.test('JWT Authentication middleware', async () => {
Expand Down
67 changes: 65 additions & 2 deletions src/adapter/bun/serve-static.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { stat } from 'node:fs/promises'
import type { FileHandle } 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 {
PartialContents,
RangeRequest,
ServeStaticOptions,
} from '../../middleware/serve-static'
import type { Env, MiddlewareHandler } from '../../types'

function readRange(
handle: FileHandle,
o: {
start: number
end: number
size: number
}
): PartialContents['contents'][number] {
const end = Math.min(o.end, o.size - 1)
const readStream = handle.createReadStream({ start: o.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 31 in src/adapter/bun/serve-static.ts

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L30-L31

Added lines #L30 - L31 were not covered by tests
})
},

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L33 was not covered by tests
})
return {
start: o.start,
end,
data,
}
}

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L40 was not covered by tests

export const serveStatic = <E extends Env = Env>(
options: ServeStaticOptions<E>
): MiddlewareHandler => {
Expand All @@ -14,6 +49,33 @@ export const serveStatic = <E extends Env = Env>(
const file = Bun.file(path)
return (await file.exists()) ? file : null
}
const getPartialContents = async (
path: string,
rangeRequest: RangeRequest
): Promise<PartialContents | null> => {
path = path.startsWith('/') ? path : `./${path}`
const handle = await open(path)
const size = (await handle.stat()).size
switch (rangeRequest.type) {
case 'last':
return {
contents: [readRange(handle, { start: size - rangeRequest.last, end: size, size })],
totalSize: size,
}
case 'range':
return {
contents: [
readRange(handle, { start: rangeRequest.start, end: rangeRequest.end ?? size, size }),
],
totalSize: size,
}
case 'ranges':
return {
contents: rangeRequest.ranges.map((range) => readRange(handle, { ...range, size })),
totalSize: size,
}
}
}

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

View check run for this annotation

Codecov / codecov/patch

src/adapter/bun/serve-static.ts#L77-L78

Added lines #L77 - L78 were not covered by tests
const pathResolve = (path: string) => {
return path.startsWith('/') ? path : `./${path}`
}
Expand All @@ -28,6 +90,7 @@ export const serveStatic = <E extends Env = Env>(
return baseServeStatic({
...options,
getContent,
getPartialContents,
pathResolve,
isDir,
})(c, next)
Expand Down
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,
})
}
// getPartialContents is not implemented since this middleware is deprecated
return baseServeStatic({
...options,
getContent,
Expand Down
7 changes: 6 additions & 1 deletion src/adapter/deno/serve-static.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ServeStaticOptions } from '../../middleware/serve-static'
import type { RangeRequest, ServeStaticOptions } from '../../middleware/serve-static'

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L1 was not covered by tests
import { serveStatic as baseServeStatic } from '../../middleware/serve-static'
import type { Env, MiddlewareHandler } from '../../types'

Expand All @@ -19,6 +19,10 @@ export const serveStatic = <E extends Env = Env>(
console.warn(`${e}`)
}
}
const getPartialContents = async (path: string, rangeRequest: RangeRequest) => {

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

View workflow job for this annotation

GitHub Actions / Main

'path' is defined but never used

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

View workflow job for this annotation

GitHub Actions / Main

'rangeRequest' is defined but never used

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L22 was not covered by tests
// TODO: Implement getPartialContents
return null
}

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

View check run for this annotation

Codecov / codecov/patch

src/adapter/deno/serve-static.ts#L24-L25

Added lines #L24 - L25 were not covered by tests
const pathResolve = (path: string) => {
return path.startsWith('/') ? path : `./${path}`
}
Expand All @@ -33,6 +37,7 @@ export const serveStatic = <E extends Env = Env>(
return baseServeStatic({
...options,
getContent,
getPartialContents,

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L40 was not covered by tests
pathResolve,
isDir,
})(c, next)
Expand Down
1 change: 1 addition & 0 deletions src/middleware/serve-static/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ describe('Serve Static Middleware', () => {
// TODO: 416 Range not satisfiable
// TODO: compression
// TODO: Date, Cache-Control, ETag, Expires, Content-Location, and Vary.
// TODO: If-Range

it('supports multiple ranges byte=N1-N2,N3-N4,...', async () => {
const getPartialContents = vi.fn(async (path, rangeRequest) => {
Expand Down
19 changes: 13 additions & 6 deletions src/middleware/serve-static/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ const formatRangeSize = (start: number, end: number, totalSize: number | undefin
}

export type RangeRequest =
| { start: number; end: number | undefined }
| { last: number }
| { ranges: Array<{ start: number; end: number }> }
| { type: 'range'; start: number; end: number | undefined }
| { type: 'last'; last: number }
| { type: 'ranges'; ranges: Array<{ start: number; end: number }> }

// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
const decodeRangeRequestHeader = (raw: string | undefined): RangeRequest | undefined => {
Expand All @@ -52,12 +52,16 @@ const decodeRangeRequestHeader = (raw: string | undefined): RangeRequest | undef
const bytesContent = bytes[1].trim()
const last = bytesContent.match(/^-(\d+)$/)
if (last) {
return { last: parseInt(last[1]) }
return { type: 'last', last: parseInt(last[1]) }
}

const single = bytesContent.match(/^(\d+)-(\d+)?$/)
if (single) {
return { start: parseInt(single[1]), end: single[2] ? parseInt(single[2]) : undefined }
return {
type: 'range',
start: parseInt(single[1]),
end: single[2] ? parseInt(single[2]) : undefined,
}
}

const multiple = bytesContent.match(/^(\d+-\d+(?:,\s*\d+-\d+)+)$/)
Expand All @@ -66,10 +70,13 @@ const decodeRangeRequestHeader = (raw: string | undefined): RangeRequest | undef
const [start, end] = range.split('-').map((n) => parseInt(n.trim()))
return { start, end }
})
return { ranges }
return { type: 'ranges', ranges }
}
}

// RFC 9110 https://www.rfc-editor.org/rfc/rfc9110#field.range
// - An origin server MUST ignore a Range header field that contains a range unit it does not understand.
// - A server that supports range requests MAY ignore or reject a Range header field that contains an invalid ranges-specifier.
return undefined
}

Expand Down

0 comments on commit d29d1e9

Please sign in to comment.