Skip to content

Commit

Permalink
feat: range header support
Browse files Browse the repository at this point in the history
  • Loading branch information
vasco-santos committed Jun 30, 2022
1 parent c090a3b commit ab25929
Show file tree
Hide file tree
Showing 8 changed files with 135 additions and 11 deletions.
1 change: 1 addition & 0 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
},
"dependencies": {
"@supabase/postgrest-js": "^0.37.2",
"http-range-parse": "^1.0.0",
"itty-router": "^2.4.5",
"multiformats": "^9.6.4",
"nanoid": "^3.1.30",
Expand Down
9 changes: 8 additions & 1 deletion packages/api/src/env.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,16 @@ function getSentry(request, env, ctx) {
* @property {Headers} [httpMetadata]
* @property {Record<string, string>} [customMetadata]
*
* @typedef {{ offset: number, length?: number } | { offset?: number, length: number } | { suffix: number }} R2Range
*
* @typedef {Object} R2GetOptions
* @property {R2Range} [range]
*
* @typedef {Object} R2Object
* @property {Date} uploaded
* @property {number} size
* @property {string} httpEtag
* @property {(headers: Headers) => void} writeHttpMetadata
* @property {Headers} [httpMetadata]
* @property {Record<string, string>} [customMetadata]
*
Expand All @@ -144,7 +151,7 @@ function getSentry(request, env, ctx) {
*
* @typedef {Object} R2Bucket
* @property {(key: string) => Promise<R2Object | null>} head
* @property {(key: string) => Promise<R2ObjectBody | null>} get
* @property {(key: string, options?: R2GetOptions) => Promise<Response & R2Object | null>} get
* @property {(key: string, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | null, options?: R2PutOptions) => Promise<R2Object>} put
* @property {(key: string) => Promise<void>} delete
*/
14 changes: 14 additions & 0 deletions packages/api/src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ export class TimeoutError extends Error {
}
TimeoutError.CODE = 'ERROR_TIMEOUT'

export class InvalidRangeError extends Error {
/**
* @param {string} message
*/
constructor(message = 'invalid Range') {
const status = 400
super(message)
this.name = 'InvalidRangeError'
this.status = status
this.code = InvalidRangeError.CODE
}
}
InvalidRangeError.CODE = 'ERROR_INVALID_RANGE'

export class HTTPError extends Error {
/**
*
Expand Down
63 changes: 58 additions & 5 deletions packages/api/src/perma-cache/get.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/* eslint-env serviceworker, browser */
/* global Response */

import parseRange from 'http-range-parse'

import { getSourceUrl, getNormalizedUrl } from '../utils/url.js'
import { UrlNotFoundError } from '../errors.js'
import { UrlNotFoundError, InvalidRangeError } from '../errors.js'

/**
* @typedef {import('../env').Env} Env
* @typedef {import('../env').R2Range} R2Range
*/

/**
Expand All @@ -19,10 +22,60 @@ export async function permaCacheGet(request, env) {
const normalizedUrl = getNormalizedUrl(sourceUrl, env)
const r2Key = normalizedUrl.toString()

const r2Object = await env.SUPERHOT.get(r2Key)
if (r2Object) {
return new Response(r2Object.body)
// Get range header
const range = toR2Range(request.headers.get('range'))
const r2Object = await env.SUPERHOT.get(r2Key, {
range,
})

if (!r2Object || !r2Object.body) {
throw new UrlNotFoundError()
}

const headers = new Headers()
headers.set('etag', r2Object.httpEtag)
r2Object.writeHttpMetadata(headers)

if (range) {
headers.set('status', '206')
let first, last
if (range.suffix != null) {
first = r2Object.size - range.suffix
last = r2Object.size - 1
} else {
first = range.offset || 0
last = range.length != null ? first + range.length - 1 : r2Object.size - 1
}
headers.set('content-range', `bytes ${first}-${last}/${r2Object.size}`)
headers.set('content-length', `${last - first + 1}`)
} else {
headers.set('status', '200')
headers.set('content-length', `${r2Object.size}`)
}

return new Response(r2Object.body, {
status: range ? 206 : 200,
})
}

/**
* Convert a HTTP Range header to an R2 range object.
*
* @param {string|null} encoded
* @returns {R2Range|undefined}
*/
function toR2Range(encoded) {
if (encoded === null) {
return
}

throw new UrlNotFoundError()
const result = parseRange(encoded)
if (result.ranges)
throw new InvalidRangeError('Multiple ranges not supported')
const { unit, first, last, suffix } = result
if (unit !== 'bytes')
throw new InvalidRangeError(`Unsupported range unit: ${unit}`)
return suffix != null
? { suffix }
: { offset: first, length: last != null ? last - first + 1 : undefined }
}
30 changes: 30 additions & 0 deletions packages/api/test/perma-cache-get.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,36 @@ test('Gets content from perma cache by URL', async (t) => {
t.deepEqual(await responseGet.text(), gatewayTxtResponse)
})

test('Gets range content from perma cache by URL', async (t) => {
const { mf, user } = t.context

const url =
'http://localhost:9081/ipfs/bafkreidyeivj7adnnac6ljvzj2e3rd5xdw3revw4da7mx2ckrstapoupoq'
const gatewayTxtResponse = 'Hello nft.storage! 😎'

// Post URL content to perma cache
const responsePost = await mf.dispatchFetch(getPermaCachePutUrl(url), {
method: 'POST',
headers: { Authorization: `Bearer ${user.token}` },
})
t.is(responsePost.status, 200)

// GET URL content from perma cache
const { normalizedUrl } = getParsedUrl(url)

const responseGet = await mf.dispatchFetch(
getPermaCachePutUrl(normalizedUrl),
{
method: 'GET',
headers: {
Range: 'bytes=0-2',
},
}
)
t.is(responseGet.status, 206)
t.deepEqual(await responseGet.text(), gatewayTxtResponse.slice(0, 2 + 1)) // range includes
})

test('Gets 404 response from perma cache by URL when url not perma cached', async (t) => {
const { mf } = t.context
const url =
Expand Down
10 changes: 8 additions & 2 deletions packages/api/test/scripts/mocks/r2.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,25 @@ export function createR2Bucket() {
size: data.length,
})
},
get: async (key) => {
get: async (key, options = {}) => {
const value = bucket.get(key)
if (!value) {
return undefined
}

const response = new Response(value.body, { status: 200 })
let body = value.body
if (options.range) {
body = value.body.slice(options.range.offset, options.range.length)
}

const response = new Response(body, { status: 200 })

return Promise.resolve(
Object.assign(response, {
httpMetadata: value.httpMetadata || {},
customMetadata: value.customMetadata || {},
size: value.body.length,
writeHttpMetadata: () => {},
})
)
},
Expand Down
10 changes: 7 additions & 3 deletions packages/edge-gateway/src/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,9 +159,9 @@ export async function gatewayIpfs(request, env, ctx, options = {}) {
winnerGwResponse.response.headers.get('content-length')
)

// Cache request URL in Cloudflare CDN if smaller than CF_CACHE_MAX_OBJECT_SIZE
// Cache request in Cloudflare CDN if smaller than CF_CACHE_MAX_OBJECT_SIZE
if (contentLengthMb <= CF_CACHE_MAX_OBJECT_SIZE) {
await cache.put(request.url, winnerGwResponse.response.clone())
await cache.put(request, winnerGwResponse.response.clone())
}
})()
)
Expand Down Expand Up @@ -273,7 +273,11 @@ async function cdnResolution(request, env, cache) {

try {
const res = await pAny(
[cache.match(request.url), getFromPermaCache(request, env)],
[
cache.match(request), // Request from cache API
cache.match(request.url), // Request URL from cache API - To be deprecated
getFromPermaCache(request, env),
],
{
filter: (res) => !!res,
}
Expand Down
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit ab25929

Please sign in to comment.