Skip to content

Commit

Permalink
feat: support redirects for UnixFS directories (#5)
Browse files Browse the repository at this point in the history
* feat: support redirects for UnixFS directories

Adds support for simulating redirects for UnixFS directories.

We're somewhat in uncharted water here because window.fetch does this
transparently unless you specify a `redirect` option, none of which
actually allow you to follow a redirect.

The states we can be in are:

1. URL: `ipfs://QmFoo/dir/`
  - Happy path
  - 200 response
  - `response.redirected = false`
  - `response.url = 'ipfs://QmFoo/dir'`

2: URL: `ipfs://QmFoo/dir`, `redirect: 'follow'`
  - The default value
  - Simulates automatically following a redirect
  - 200 response
  - `response.redirected = true`
  - `response.url = 'ipfs://QmFoo/dir/'`

3: URL: `ipfs://QmFoo/dir`, `redirect: 'error'`
  - Return an error if a redirect would take place
  - Throws `TypeError('Failed to Fetch')` same as `window.fetch`

4: URL: `ipfs://QmFoo/dir`, `redirect: 'manual'`
  - Allows a caller to take action on the redirect
  - 301 response
  - `response.redirected = false`
  - `response.url = 'ipfs://QmFoo/dir`
  - `response.headers.get('location') = 'ipfs://QmFoo/dir/'`

Number 4 is the furthest from [the fetch spec](https://fetch.spec.whatwg.org/#concept-request-redirect-mode)
but to follow the spec would make it impossible to actually follow a
redirect.

* chore: document redirect option
  • Loading branch information
achingbrain committed Mar 3, 2024
1 parent e7f1816 commit 4601d46
Show file tree
Hide file tree
Showing 5 changed files with 336 additions and 26 deletions.
73 changes: 73 additions & 0 deletions packages/verified-fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,79 @@ console.info(res.headers.get('accept')) // application/octet-stream
const buf = await res.arrayBuffer() // raw bytes, not processed as JSON
```

## Redirects

If a requested URL contains a path component, that path component resolves to
a UnixFS directory, but the URL does not have a trailing slash, one will be
added to form a canonical URL for that resource, otherwise the request will
be resolved as normal.

```typescript
import { verifiedFetch } from '@helia/verified-fetch'

const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir')

console.info(res.url) // ipfs://bafyfoo/path/to/dir/
```

It's possible to prevent this behaviour and/or handle a redirect manually
through use of the [redirect](https://developer.mozilla.org/en-US/docs/Web/API/fetch#redirect)
option.

## Example - Redirect: follow

This is the default value and is what happens if no value is specified.

```typescript
import { verifiedFetch } from '@helia/verified-fetch'

const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir', {
redirect: 'follow'
})

console.info(res.status) // 200
console.info(res.url) // ipfs://bafyfoo/path/to/dir/
console.info(res.redirected) // true
```

## Example - Redirect: error

This causes a `TypeError` to be thrown if a URL would cause a redirect.

```typescript

import { verifiedFetch } from '@helia/verified-fetch'

const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir', {
redirect: 'error'
})
// throw TypeError('Failed to fetch')
```

## Example - Redirect: manual

Manual redirects allow the user to process the redirect. A [301](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301)
is returned, and the location to redirect to is available as the "location"
response header.

This differs slightly from HTTP fetch which returns an opaque response as the
browser itself is expected to process the redirect and hide all details from
the user.

```typescript

import { verifiedFetch } from '@helia/verified-fetch'

const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir', {
redirect: 'manual'
})

console.info(res.status) // 301
console.info(res.url) // ipfs://bafyfoo/path/to/dir
console.info(res.redirected) // false
console.info(res.headers.get('location')) // ipfs://bafyfoo/path/to/dir/
```

## Comparison to fetch

This module attempts to act as similarly to the `fetch()` API as possible.
Expand Down
73 changes: 73 additions & 0 deletions packages/verified-fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,79 @@
* const buf = await res.arrayBuffer() // raw bytes, not processed as JSON
* ```
*
* ## Redirects
*
* If a requested URL contains a path component, that path component resolves to
* a UnixFS directory, but the URL does not have a trailing slash, one will be
* added to form a canonical URL for that resource, otherwise the request will
* be resolved as normal.
*
* ```typescript
* import { verifiedFetch } from '@helia/verified-fetch'
*
* const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir')
*
* console.info(res.url) // ipfs://bafyfoo/path/to/dir/
* ```
*
* It's possible to prevent this behaviour and/or handle a redirect manually
* through use of the [redirect](https://developer.mozilla.org/en-US/docs/Web/API/fetch#redirect)
* option.
*
* @example Redirect: follow
*
* This is the default value and is what happens if no value is specified.
*
* ```typescript
* import { verifiedFetch } from '@helia/verified-fetch'
*
* const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir', {
* redirect: 'follow'
* })
*
* console.info(res.status) // 200
* console.info(res.url) // ipfs://bafyfoo/path/to/dir/
* console.info(res.redirected) // true
* ```
*
* @example Redirect: error
*
* This causes a `TypeError` to be thrown if a URL would cause a redirect.
*
* ```typescript
*
* import { verifiedFetch } from '@helia/verified-fetch'
*
* const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir', {
* redirect: 'error'
* })
* // throw TypeError('Failed to fetch')
* ```
*
* @example Redirect: manual
*
* Manual redirects allow the user to process the redirect. A [301](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/301)
* is returned, and the location to redirect to is available as the "location"
* response header.
*
* This differs slightly from HTTP fetch which returns an opaque response as the
* browser itself is expected to process the redirect and hide all details from
* the user.
*
* ```typescript
*
* import { verifiedFetch } from '@helia/verified-fetch'
*
* const res = await verifiedFetch('ipfs://bafyfoo/path/to/dir', {
* redirect: 'manual'
* })
*
* console.info(res.status) // 301
* console.info(res.url) // ipfs://bafyfoo/path/to/dir
* console.info(res.redirected) // false
* console.info(res.headers.get('location')) // ipfs://bafyfoo/path/to/dir/
* ```
*
* ## Comparison to fetch
*
* This module attempts to act as similarly to the `fetch()` API as possible.
Expand Down
83 changes: 76 additions & 7 deletions packages/verified-fetch/src/utils/responses.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,98 @@
export function okResponse (body?: BodyInit | null): Response {
return new Response(body, {
function setField (response: Response, name: string, value: string | boolean): void {
Object.defineProperty(response, name, {
enumerable: true,
configurable: false,
set: () => {},
get: () => value
})
}

function setType (response: Response, value: 'basic' | 'cors' | 'error' | 'opaque' | 'opaqueredirect'): void {
setField(response, 'type', value)
}

function setUrl (response: Response, value: string): void {
setField(response, 'url', value)
}

function setRedirected (response: Response): void {
setField(response, 'redirected', true)
}

export interface ResponseOptions extends ResponseInit {
redirected?: boolean
}

export function okResponse (url: string, body?: BodyInit | null, init?: ResponseOptions): Response {
const response = new Response(body, {
...(init ?? {}),
status: 200,
statusText: 'OK'
})

if (init?.redirected === true) {
setRedirected(response)
}

setType(response, 'basic')
setUrl(response, url)

return response
}

export function notSupportedResponse (body?: BodyInit | null): Response {
export function notSupportedResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 501,
statusText: 'Not Implemented'
})
response.headers.set('X-Content-Type-Options', 'nosniff') // see https://specs.ipfs.tech/http-gateways/path-gateway/#x-content-type-options-response-header

setType(response, 'basic')
setUrl(response, url)

return response
}

export function notAcceptableResponse (body?: BodyInit | null): Response {
return new Response(body, {
export function notAcceptableResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 406,
statusText: 'Not Acceptable'
})

setType(response, 'basic')
setUrl(response, url)

return response
}

export function badRequestResponse (body?: BodyInit | null): Response {
return new Response(body, {
export function badRequestResponse (url: string, body?: BodyInit | null, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 400,
statusText: 'Bad Request'
})

setType(response, 'basic')
setUrl(response, url)

return response
}

export function movedPermanentlyResponse (url: string, location: string, init?: ResponseInit): Response {
const response = new Response(null, {
...(init ?? {}),
status: 301,
statusText: 'Moved Permanently',
headers: {
...(init?.headers ?? {}),
location
}
})

setType(response, 'basic')
setUrl(response, url)

return response
}
Loading

0 comments on commit 4601d46

Please sign in to comment.