Skip to content

Commit

Permalink
feat(node-utils): uses host header and mocks waitUntil() (vercel#244)
Browse files Browse the repository at this point in the history
* feat(node-utils): uses incoming host header to determine request's origin

* feat(node-utils): provides mocked fetch event which throws on waitUntil accesses

* fix(ci): reverts failing version bump for jest

* chore(ci): fixes poisoned turbo cache?
  • Loading branch information
feugy authored and jridgewell committed Jun 23, 2023
1 parent 2d26e28 commit 872c932
Show file tree
Hide file tree
Showing 15 changed files with 629 additions and 460 deletions.
5 changes: 5 additions & 0 deletions .changeset/curvy-bikes-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@edge-runtime/node-utils': major
---

Provides mocked fetch event which throws when using `waitUntil`. `buildToNodeHandler()`'s dependencies now requires `FetchEvent` constructor.
5 changes: 5 additions & 0 deletions .changeset/dry-cheetahs-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@edge-runtime/node-utils': major
---

Uses host header as request origin when available. `buildToNodeHandler()`'s `origin` option has been renamed into `defaultOrigin`.
35 changes: 16 additions & 19 deletions docs/pages/packages/node-utils.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,10 @@ The **@edge-runtime/node-utils** package contains utilities to run web compliant

## Installation

<Tabs items={['npm', 'yarn', 'pnpm']} storageKey="selected-pkg-manager">
<Tab>
```sh
npm install @edge-runtime/node-utils --save
```
</Tab>
<Tab>
```sh
yarn add @edge-runtime/node-utils
```
</Tab>
<Tab>
```sh
pnpm install @edge-runtime/node-utils --save
```
</Tab>
<Tabs items={['npm', 'yarn', 'pnpm']} storageKey='selected-pkg-manager'>
<Tab>```sh npm install @edge-runtime/node-utils --save ```</Tab>
<Tab>```sh yarn add @edge-runtime/node-utils ```</Tab>
<Tab>```sh pnpm install @edge-runtime/node-utils --save ```</Tab>
</Tabs>

This package includes built-in TypeScript support.
Expand All @@ -40,7 +28,7 @@ import { buildToNodeHandler } from '@edge-runtime/node-utils'

// 1. builds a transformer, using Node.js@18 globals, and a base url for URL constructor.
const transformToNode = buildToNodeHandler(global, {
origin: 'http://example.com',
defaultOrigin: 'http://example.com',
})

const server = await createServer(
Expand Down Expand Up @@ -80,6 +68,7 @@ List of Web globals used by the transformer function:
- [Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
- [ReadableStream](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
- [Uint8Array](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array)
- [FetchEvent](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent)

When running in Node.js 18+ (or Node.js 16 with [--experimental-fetch](https://nodejs.org/docs/latest-v16.x/api/cli.html#node_optionsoptions)), you may pass the ones from `global` scope.

Expand All @@ -106,15 +95,23 @@ buildToNodeHandler(primitives, {

Options used to build the transformer function, including:

##### origin: string
##### defaultOrigin: string

Origin used to turn the incoming request relative url into a full [Request.url](https://developer.mozilla.org/en-US/docs/Web/API/Request/url) string.
The incoming [`host` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/host) is used when turning the incoming request relative url into a full [Request.url](https://developer.mozilla.org/en-US/docs/Web/API/Request/url) string.
In case no host is provided, this default origin will be used instead.

### buildToRequest(dependencies, options): toRequest(request: IncomingMessage, options: object): Request

Builds a transformer function to turn a Node.js [IncomingMessage](https://nodejs.org/api/http.html#class-httpincomingmessage) into a Web [Request].
It needs globals Web contstructor a [dependencies](#dependencies-object), as well as [options](#options-object) to handle incoming url.

### buildToFetchEvent(dependencies): toFetchEvent(request: Request): FetchEvent

Builds a transformer function to build a fetch event from a web [Request].
The returned event is linked to provided request, and has a mocked [waitUntil()](https://developer.mozilla.org/en-US/docs/Web/API/ExtendableEvent/waitUntil) method, which throws on access.

It needs globals Web contstructor a [dependencies](#dependencies-object).

### toOutgoingHeaders(headers: Headers): OutgoingHttpHeaders

Turns Web [Request.headers](https://developer.mozilla.org/en-US/docs/Web/API/Request/headers) into
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
"esbuild": "0.17.4",
"finepack": "latest",
"git-authors-cli": "latest",
"jest": "29.4.0",
"jest": "29.3.1",
"jest-watch-typeahead": "2.2.1",
"nano-staged": "latest",
"prettier": "latest",
Expand Down
16 changes: 11 additions & 5 deletions packages/node-utils/src/edge-to-node/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
BuildDependencies,
RequestOptions,
} from '../types'
import { buildToFetchEvent } from '../node-to-edge/fetch-event'
import { buildToRequest } from '../node-to-edge/request'
import { mergeIntoServerResponse, toOutgoingHeaders } from './headers'
import { toToReadable } from './stream'
Expand All @@ -14,15 +15,20 @@ export function buildToNodeHandler(
options: RequestOptions
) {
const toRequest = buildToRequest(dependencies)
const toFetchEvent = buildToFetchEvent(dependencies)
return function toNodeHandler(webHandler: WebHandler): NodeHandler {
return (request: IncomingMessage, response: ServerResponse) => {
const maybePromise = webHandler(toRequest(request, options))
return (
incomingMessage: IncomingMessage,
serverResponse: ServerResponse
) => {
const request = toRequest(incomingMessage, options)
const maybePromise = webHandler(request, toFetchEvent(request))
if (maybePromise instanceof Promise) {
maybePromise.then((webResponse) =>
toServerResponse(webResponse, response)
maybePromise.then((response) =>
toServerResponse(response, serverResponse)
)
} else {
toServerResponse(maybePromise, response)
toServerResponse(maybePromise, serverResponse)
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions packages/node-utils/src/node-to-edge/fetch-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Request } from '@edge-runtime/primitives'
import { BuildDependencies } from '../types'

export function buildToFetchEvent(dependencies: BuildDependencies) {
return function toFetchEvent(request: Request) {
const event = new dependencies.FetchEvent(request)
Object.defineProperty(event, 'waitUntil', {
configurable: false,
enumerable: true,
get: () => {
throw new Error('waitUntil is not supported yet.')
},
})
return event
}
}
1 change: 1 addition & 0 deletions packages/node-utils/src/node-to-edge/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './fetch-event'
export * from './headers'
export * from './request'
export * from './stream'
31 changes: 24 additions & 7 deletions packages/node-utils/src/node-to-edge/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,29 @@ export function buildToRequest(dependencies: BuildDependencies) {
request: IncomingMessage,
options: RequestOptions
): Request {
return new Request(String(new URL(request.url || '/', options.origin)), {
method: request.method,
headers: toHeaders(request.headers),
body: !['HEAD', 'GET'].includes(request.method ?? '')
? toReadableStream(request)
: null,
})
return new Request(
String(
new URL(
request.url || '/',
computeOrigin(request, options.defaultOrigin)
)
),
{
method: request.method,
headers: toHeaders(request.headers),
body: !['HEAD', 'GET'].includes(request.method ?? '')
? toReadableStream(request)
: null,
}
)
}
}

function computeOrigin({ headers }: IncomingMessage, defaultOrigin: string) {
const authority = headers.host
if (!authority) {
return defaultOrigin
}
const [, port] = authority.split(':')
return `${port === '443' ? 'https' : 'http'}://${authority}`
}
7 changes: 5 additions & 2 deletions packages/node-utils/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@ import type {
Response,
Headers,
ReadableStream,
FetchEvent,
} from '@edge-runtime/primitives'
export interface BuildDependencies {
Headers: typeof Headers
ReadableStream: typeof ReadableStream
Request: typeof Request
Uint8Array: typeof Uint8Array
FetchEvent: typeof FetchEvent
}

export interface RequestOptions {
origin: string
defaultOrigin: string
}

export type NodeHandler = (
Expand All @@ -22,5 +24,6 @@ export type NodeHandler = (
) => Promise<void> | void

export type WebHandler = (
req: Request
req: Request,
event: FetchEvent
) => Promise<Response> | Response | null | undefined
19 changes: 18 additions & 1 deletion packages/node-utils/test/edge-to-node/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ const transformToNode = buildToNodeHandler(
ReadableStream: Edge.ReadableStream,
Request: Edge.Request,
Uint8Array: Uint8Array,
FetchEvent: Edge.FetchEvent,
},
{ origin: 'http://example.com' }
{ defaultOrigin: 'http://example.com' }
)

let server: TestServer
Expand Down Expand Up @@ -229,3 +230,19 @@ it('consumes incoming headers', async () => {
headers,
})
})

it('fails when using waitUntil()', async () => {
server = await runTestServer({
handler: transformToNode((req, evt) => {
evt.waitUntil(Promise.resolve())
return new Edge.Response('ok')
}),
})

const response = await server.fetch('/')
expect(await serializeResponse(response)).toMatchObject({
status: 500,
statusText: 'Internal Server Error',
text: 'Error: waitUntil is not supported yet.',
})
})
25 changes: 25 additions & 0 deletions packages/node-utils/test/node-to-edge/fetch-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as EdgeRuntime from '@edge-runtime/primitives'
import { buildToFetchEvent } from '../../src'

const toFetchEvent = buildToFetchEvent({
Headers: EdgeRuntime.Headers,
ReadableStream: EdgeRuntime.ReadableStream,
Request: EdgeRuntime.Request,
Uint8Array: Uint8Array,
FetchEvent: EdgeRuntime.FetchEvent,
})

it('returns a fetch event with a request', () => {
const request = new EdgeRuntime.Request('https://vercel.com')
const event = toFetchEvent(request)
expect(event).toBeInstanceOf(EdgeRuntime.FetchEvent)
expect(event.request).toBe(request)
})

it('throws when accessing waitUntil', () => {
const request = new EdgeRuntime.Request('https://vercel.com')
const event = toFetchEvent(request)
expect(() => event.waitUntil(Promise.resolve())).toThrow(
'waitUntil is not supported yet.'
)
})
97 changes: 58 additions & 39 deletions packages/node-utils/test/node-to-edge/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const nodeRequestToRequest = buildToRequest({
ReadableStream: EdgeRuntime.ReadableStream,
Request: EdgeRuntime.Request,
Uint8Array: Uint8Array,
FetchEvent: EdgeRuntime.FetchEvent,
})

let requestMap = new Map<string, Request>()
Expand All @@ -23,7 +24,9 @@ beforeAll(async () => {
return
}

const request = nodeRequestToRequest(incoming, { origin: server.url })
const request = nodeRequestToRequest(incoming, {
defaultOrigin: server.url,
})
const [body, readable] = request.body?.tee() ?? []
requestMap.set(requestId, new EdgeRuntime.Request(request, { body }))
response.writeHead(200, { 'Content-Type': 'text/plain' })
Expand Down Expand Up @@ -70,50 +73,66 @@ it('maps the request headers`', async () => {
)
})

describe('maps the request body', () => {
it('allows to read the body as text', async () => {
const request = await mapRequest(server.url, {
body: 'Hello World',
method: 'POST',
})
it(`uses default origin as request url origin when there are no host header`, async () => {
const request = await mapRequest(server.url)
expect(request.url).toEqual(`${server.url}/`)
})

it(`uses request host header as request url origin`, async () => {
const host = 'vercel.com'
await expect(
mapRequest(server.url, { headers: { host } })
).resolves.toHaveProperty('url', `http://${host}/`)
await expect(
mapRequest(server.url, { headers: { host: `${host}:6000` } })
).resolves.toHaveProperty('url', `http://${host}:6000/`)
await expect(
mapRequest(server.url, { headers: { host: `${host}:443` } })
).resolves.toHaveProperty('url', `https://${host}/`)
})

it('allows to read the body as text', async () => {
const request = await mapRequest(server.url, {
body: 'Hello World',
method: 'POST',
})

expect(request.method).toEqual('POST')
expect(await request.text()).toEqual('Hello World')
})

expect(request.method).toEqual('POST')
expect(await request.text()).toEqual('Hello World')
it('allows to read the body as chunks', async () => {
const encoder = new EdgeRuntime.TextEncoder()
const body = new EdgeRuntime.ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('Hello '))
setTimeout(() => {
controller.enqueue(encoder.encode('World'))
controller.close()
}, 500)
},
})

it('allows to read the body as chunks', async () => {
const encoder = new EdgeRuntime.TextEncoder()
const body = new EdgeRuntime.ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('Hello '))
setTimeout(() => {
controller.enqueue(encoder.encode('World'))
controller.close()
}, 500)
},
})

const request = await mapRequest(server.url, {
method: 'POST',
body,
})

expect(request.method).toEqual('POST')
expect(await request.text()).toEqual('Hello World')
const request = await mapRequest(server.url, {
method: 'POST',
body,
})

it('does not allow to read the body twice', async () => {
const request = await mapRequest(server.url, {
body: 'Hello World',
method: 'POST',
})

expect(request.method).toEqual('POST')
expect(await request.text()).toEqual('Hello World')
await expect(request.text()).rejects.toThrowError(
'The body has already been consumed.'
)
expect(request.method).toEqual('POST')
expect(await request.text()).toEqual('Hello World')
})

it('does not allow to read the body twice', async () => {
const request = await mapRequest(server.url, {
body: 'Hello World',
method: 'POST',
})

expect(request.method).toEqual('POST')
expect(await request.text()).toEqual('Hello World')
await expect(request.text()).rejects.toThrowError(
'The body has already been consumed.'
)
})

async function mapRequest(input: string, init: RequestInit = {}) {
Expand Down
Loading

0 comments on commit 872c932

Please sign in to comment.