Skip to content

Commit

Permalink
[node] swap undici.fetch for undici.request in `serverless-handle…
Browse files Browse the repository at this point in the history
…r.mts` (#10767)

In a recent undici update, setting the `host` header for fetch requests became invalid (nodejs/undici#2322). 

We relied on this in order to proxy serverless dev server requests via `@vercel/node`. 

This PR replaces the usage of `undici.fetch` with `undici.request`. 

It is blocked by an `undici` type change: nodejs/undici#2373
  • Loading branch information
Ethan-Arrowood committed Nov 8, 2023
1 parent ebd7e3a commit 89c1e03
Show file tree
Hide file tree
Showing 8 changed files with 48 additions and 70 deletions.
5 changes: 5 additions & 0 deletions .changeset/clean-cherries-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vercel/node': patch
---

Replace usage of `fetch` with `undici.request`
2 changes: 1 addition & 1 deletion packages/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"ts-morph": "12.0.0",
"ts-node": "10.9.1",
"typescript": "4.9.5",
"undici": "5.23.0"
"undici": "5.26.5"
},
"devDependencies": {
"@babel/core": "7.5.0",
Expand Down
3 changes: 1 addition & 2 deletions packages/node/src/dev-server.mts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { createEdgeEventHandler } from './edge-functions/edge-handler.mjs';
import { createServer, IncomingMessage, ServerResponse } from 'http';
import { createServerlessEventHandler } from './serverless-functions/serverless-handler.mjs';
import { isEdgeRuntime, logError, validateConfiguredRuntime } from './utils.js';
import { toToReadable } from '@edge-runtime/node-utils';
import { getConfig } from '@vercel/static-config';
import { Project } from 'ts-morph';
import { listen } from 'async-listen';
Expand Down Expand Up @@ -118,7 +117,7 @@ async function onDevRequest(
} else if (body instanceof Buffer) {
res.end(body);
} else {
toToReadable(body).pipe(res);
body.pipe(res);
}
} catch (error: any) {
res.statusCode = 500;
Expand Down
30 changes: 16 additions & 14 deletions packages/node/src/edge-functions/edge-handler.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ import {
NodeCompatBindings,
} from './edge-node-compat-plugin.mjs';
import { EdgeRuntime, runServer } from 'edge-runtime';
import { fetch, Headers } from 'undici';
import { type Dispatcher, Headers, request as undiciRequest } from 'undici';
import { isError } from '@vercel/error-utils';
import { readFileSync } from 'fs';
import { serializeBody, entrypointToOutputPath, logError } from '../utils.js';
import esbuild from 'esbuild';
import exitHook from 'exit-hook';
import type { HeadersInit } from 'undici';
import { buildToHeaders } from '@edge-runtime/node-utils';
import type { VercelProxyResponse } from '../types.js';
import type { IncomingMessage } from 'http';
import { fileURLToPath } from 'url';
Expand All @@ -23,6 +23,9 @@ if (!NODE_VERSION_MAJOR) {
);
}

// @ts-expect-error - depends on https://github.com/nodejs/undici/pull/2373
const toHeaders = buildToHeaders({ Headers });

const __dirname = fileURLToPath(new URL('.', import.meta.url));
const edgeHandlerTemplate = readFileSync(
`${__dirname}/edge-handler-template.js`
Expand Down Expand Up @@ -191,23 +194,22 @@ export async function createEdgeEventHandler(
process.exit(1);
}

const headers = new Headers(request.headers as HeadersInit);
const body: Buffer | string | undefined = await serializeBody(request);
if (body !== undefined) headers.set('content-length', String(body.length));
if (body !== undefined)
request.headers['content-length'] = String(body.length);

const url = new URL(request.url ?? '/', server.url);
const response = await fetch(url, {
const response = await undiciRequest(url, {
body,
headers,
method: request.method,
redirect: 'manual',
headers: request.headers,
method: (request.method || 'GET') as Dispatcher.HttpMethod,
});

const isUserError =
response.headers.get('x-vercel-failed') === 'edge-wrapper';
const resHeaders = toHeaders(response.headers) as Headers;
const isUserError = resHeaders.get('x-vercel-failed') === 'edge-wrapper';

if (isUserError && response.status >= 500) {
const body = await response.text();
if (isUserError && response.statusCode >= 500) {
const body = await response.body.text();
// We can't currently get a real stack trace from the Edge Function error,
// but we can fake a basic one that is still usefult to the user.
const fakeStackTrace = ` at (${entrypointRelativePath})`;
Expand All @@ -226,8 +228,8 @@ export async function createEdgeEventHandler(
}

return {
status: response.status,
headers: response.headers,
status: response.statusCode,
headers: resHeaders,
body: response.body,
encoding: 'utf8',
};
Expand Down
58 changes: 12 additions & 46 deletions packages/node/src/serverless-functions/serverless-handler.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ import { addHelpers } from './helpers.js';
import { createServer } from 'http';
import { serializeBody } from '../utils.js';
import exitHook from 'exit-hook';
import { Headers, fetch } from 'undici';
import { type Dispatcher, Headers, request as undiciRequest } from 'undici';
import { listen } from 'async-listen';
import { isAbsolute } from 'path';
import { pathToFileURL } from 'url';
import zlib from 'zlib';
import { buildToHeaders } from '@edge-runtime/node-utils';
import type { ServerResponse, IncomingMessage } from 'http';
import type { VercelProxyResponse } from '../types.js';
import type { VercelRequest, VercelResponse } from './helpers.js';
import type { Readable } from 'stream';

// @ts-expect-error
const toHeaders = buildToHeaders({ Headers });

type ServerlessServerOptions = {
shouldAddHelpers: boolean;
Expand All @@ -34,27 +38,6 @@ const HTTP_METHODS = [
'PATCH',
];

function compress(body: Buffer, encoding: string): Buffer {
switch (encoding) {
case 'br':
return zlib.brotliCompressSync(body, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 0,
},
});
case 'gzip':
return zlib.gzipSync(body, {
level: zlib.constants.Z_BEST_SPEED,
});
case 'deflate':
return zlib.deflateSync(body, {
level: zlib.constants.Z_BEST_SPEED,
});
default:
throw new Error(`encoding '${encoding}' not supported`);
}
}

async function createServerlessServer(userCode: ServerlessFunctionSignature) {
const server = createServer(userCode);
exitHook(() => server.close());
Expand Down Expand Up @@ -103,43 +86,26 @@ export async function createServerlessEventHandler(

return async function (request: IncomingMessage) {
const url = new URL(request.url ?? '/', server.url);
const response = await fetch(url, {
const response = await undiciRequest(url, {
body: await serializeBody(request),
compress: !isStreaming,
// @ts-expect-error
headers: {
...request.headers,
host: request.headers['x-forwarded-host'],
},
method: request.method,
redirect: 'manual',
method: (request.method || 'GET') as Dispatcher.HttpMethod,
});

let body: Buffer | null = null;
let headers: Headers = response.headers;
let body: Readable | Buffer | null = null;
let headers = toHeaders(response.headers) as Headers;

if (isStreaming) {
body = response.body;
} else {
body = Buffer.from(await response.arrayBuffer());

const contentEncoding = response.headers.get('content-encoding');
if (contentEncoding) {
body = compress(body, contentEncoding);
const clonedHeaders = [];
for (const [key, value] of response.headers.entries()) {
if (key !== 'transfer-encoding') {
// transfer-encoding is only for streaming response
clonedHeaders.push([key, value]);
}
}
clonedHeaders.push(['content-length', String(Buffer.byteLength(body))]);
headers = new Headers(clonedHeaders);
}
body = Buffer.from(await response.body.arrayBuffer());
}

return {
status: response.status,
status: response.statusCode,
headers,
body,
encoding: 'utf8',
Expand Down
3 changes: 2 additions & 1 deletion packages/node/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ServerResponse, IncomingMessage } from 'http';
import type { Headers } from 'undici';
import type { Readable } from 'stream';

export type VercelRequestCookies = { [key: string]: string };
export type VercelRequestQuery = { [key: string]: string | string[] };
Expand Down Expand Up @@ -44,6 +45,6 @@ export type NowApiHandler = VercelApiHandler;
export interface VercelProxyResponse {
status: number;
headers: Headers;
body: ReadableStream<Uint8Array> | Buffer | null;
body: Readable | Buffer | null;
encoding: BufferEncoding;
}
2 changes: 1 addition & 1 deletion packages/node/test/unit/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ function testForkDevServer(entrypoint: string) {
}
);

test("user code doesn't interfer with runtime", async () => {
test("user code doesn't interfere with runtime", async () => {
const child = testForkDevServer('./edge-self.js');
try {
const result = await readMessage(child);
Expand Down
15 changes: 10 additions & 5 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 89c1e03

Please sign in to comment.