[cli] Support root-level Middleware file in vc dev (#7973)

Adds initial support for a root-level `middleware.js` / `middleware.ts` file in the `vercel dev` CLI command. This leverages the existing Edge Function invoking logic in `@vercel/node`'s `startDevServer()` function and applies the necessary response / rewrites / mutations to the HTTP request based on the result of the middleware invocation.
This commit is contained in:
Nathan Rajlich
2022-06-27 12:56:32 -07:00
committed by GitHub
parent 570fd24e29
commit ee1211416f
16 changed files with 299 additions and 59 deletions

View File

@@ -425,10 +425,6 @@ export async function getBuildMatches(
src = extensionless;
}
// We need to escape brackets since `glob` will
// try to find a group otherwise
src = src.replace(/(\[|\])/g, '[$1]');
const files = fileList
.filter(name => name === src || minimatch(name, src, { dot: true }))
.map(name => join(cwd, name));

View File

@@ -0,0 +1,18 @@
import { Headers } from 'node-fetch';
import { IncomingHttpHeaders, OutgoingHttpHeaders } from 'http';
export function nodeHeadersToFetchHeaders(
nodeHeaders: IncomingHttpHeaders | OutgoingHttpHeaders
): Headers {
const headers = new Headers();
for (const [name, value] of Object.entries(nodeHeaders)) {
if (Array.isArray(value)) {
for (const val of value) {
headers.append(name, val);
}
} else if (typeof value !== 'undefined') {
headers.set(name, String(value));
}
}
return headers;
}

View File

@@ -1,12 +1,13 @@
import ms from 'ms';
import url, { URL } from 'url';
import http from 'http';
import fs from 'fs-extra';
import chalk from 'chalk';
import fetch from 'node-fetch';
import plural from 'pluralize';
import rawBody from 'raw-body';
import listen from 'async-listen';
import minimatch from 'minimatch';
import ms from 'ms';
import httpProxy from 'http-proxy';
import { randomBytes } from 'crypto';
import serveHandler from 'serve-handler';
@@ -16,11 +17,11 @@ import path, { isAbsolute, basename, dirname, extname, join } from 'path';
import once from '@tootallnate/once';
import directoryTemplate from 'serve-handler/src/directory';
import getPort from 'get-port';
import { ChildProcess } from 'child_process';
import isPortReachable from 'is-port-reachable';
import deepEqual from 'fast-deep-equal';
import which from 'which';
import npa from 'npm-package-arg';
import type { ChildProcess } from 'child_process';
import { getVercelIgnore, fileNameSymbol } from '@vercel/client';
import {
@@ -90,6 +91,7 @@ import {
import { ProjectEnvVariable, ProjectSettings } from '../../types';
import exposeSystemEnvs from './expose-system-envs';
import { treeKill } from '../tree-kill';
import { nodeHeadersToFetchHeaders } from './headers';
const frontendRuntimeSet = new Set(
frameworkList.map(f => f.useRuntime?.use || '@vercel/static-build')
@@ -593,7 +595,7 @@ export default class DevServer {
await this.exit();
}
if (warnings && warnings.length > 0) {
if (warnings?.length > 0) {
warnings.forEach(warning =>
this.output.warn(warning.message, null, warning.link, warning.action)
);
@@ -1337,32 +1339,6 @@ export default class DevServer {
return false;
};
/*
runDevMiddleware = async (
req: http.IncomingMessage,
res: http.ServerResponse
) => {
const { devMiddlewarePlugins } = await loadCliPlugins(
this.cwd,
this.output
);
try {
for (let plugin of devMiddlewarePlugins) {
const result = await plugin.plugin.runDevMiddleware(req, res, this.cwd);
if (result.finished) {
return result;
}
}
return { finished: false };
} catch (e) {
return {
finished: true,
error: e,
};
}
};
*/
/**
* Serve project directory as a v2 deployment.
*/
@@ -1430,12 +1406,45 @@ export default class DevServer {
let prevUrl = req.url;
let prevHeaders: HttpHeadersConfig = {};
/*
const middlewareResult = await this.runDevMiddleware(req, res);
// Run the middleware file, if present, and apply any
// mutations to the incoming request based on the
// result of the middleware invocation.
const middleware = [...this.buildMatches.values()].find(
m => m.config?.middleware === true
);
if (middleware) {
let startMiddlewareResult: StartDevServerResult | undefined;
// TODO: can we add some caching to prevent (re-)starting
// the middleware server for every HTTP request?
const { envConfigs, files, devCacheDir, cwd: workPath } = this;
try {
startMiddlewareResult =
await middleware.builderWithPkg.builder.startDevServer?.({
files,
entrypoint: middleware.entrypoint,
workPath,
repoRootPath: this.cwd,
config: middleware.config || {},
meta: {
isDev: true,
devCacheDir,
env: { ...envConfigs.runEnv },
buildEnv: { ...envConfigs.buildEnv },
},
});
} catch (err) {
// `startDevServer()` threw an error. Most likely this means the dev
// server process exited before sending the port information message
// (missing dependency at runtime, for example).
if (err.code === 'ENOENT') {
err.message = `Command not found: ${chalk.cyan(
err.path,
...err.spawnargs
)}\nPlease ensure that ${cmd(err.path)} is properly installed`;
err.link = 'https://vercel.link/command-not-found';
}
if (middlewareResult) {
if (middlewareResult.error) {
this.sendError(
await this.sendError(
req,
res,
requestId,
@@ -1444,23 +1453,105 @@ export default class DevServer {
);
return;
}
if (middlewareResult.finished) {
if (startMiddlewareResult) {
const { port, pid } = startMiddlewareResult;
this.devServerPids.add(pid);
const middlewareReqHeaders = nodeHeadersToFetchHeaders(req.headers);
// Add the Vercel platform proxy request headers
const proxyHeaders = this.getProxyHeaders(req, requestId, true);
for (const [name, value] of nodeHeadersToFetchHeaders(proxyHeaders)) {
middlewareReqHeaders.set(name, value);
}
try {
const middlewareRes = await fetch(
`http://127.0.0.1:${port}${parsed.path}`,
{
headers: middlewareReqHeaders,
method: req.method,
redirect: 'manual',
}
);
if (middlewareRes.status === 500) {
await this.sendError(
req,
res,
requestId,
'EDGE_FUNCTION_INVOCATION_FAILED',
500
);
return;
}
if (middlewareResult.pathname) {
const origUrl = url.parse(req.url || '/', true);
origUrl.pathname = middlewareResult.pathname;
prevUrl = url.format(origUrl);
}
if (middlewareResult.query && prevUrl) {
const origUrl = url.parse(req.url || '/', true);
delete origUrl.search;
Object.assign(origUrl.query, middlewareResult.query);
prevUrl = url.format(origUrl);
// Apply status code from middleware invocation,
// for i.e. redirects or a custom 404 page
res.statusCode = middlewareRes.status;
let rewritePath = '';
let contentType = '';
let shouldContinue = false;
const skipMiddlewareHeaders = new Set([
'date',
'connection',
'content-length',
'transfer-encoding',
]);
for (const [name, value] of middlewareRes.headers) {
if (name === 'x-middleware-next') {
shouldContinue = value === '1';
} else if (name === 'x-middleware-rewrite') {
rewritePath = value;
shouldContinue = true;
} else if (name === 'content-type') {
contentType = value;
} else if (!skipMiddlewareHeaders.has(name)) {
// Any other kind of response header should be included
// on both the incoming HTTP request (for when proxying
// to another function) and the outgoing HTTP response.
res.setHeader(name, value);
req.headers[name] = value;
}
}
if (!shouldContinue) {
const middlewareBody = await middlewareRes.buffer();
this.setResponseHeaders(res, requestId);
if (middlewareBody.length > 0) {
res.setHeader('content-length', middlewareBody.length);
if (contentType) {
res.setHeader('content-type', contentType);
}
res.end(middlewareBody);
} else {
res.end();
}
return;
}
if (rewritePath) {
// TODO: add validation?
debug(`Detected rewrite path from middleware: "${rewritePath}"`);
prevUrl = rewritePath;
// Retain orginal pathname, but override query parameters from the rewrite
const beforeRewriteUrl = req.url || '/';
const rewriteUrlParsed = url.parse(beforeRewriteUrl, true);
delete rewriteUrlParsed.search;
rewriteUrlParsed.query = url.parse(rewritePath, true).query;
req.url = url.format(rewriteUrlParsed);
debug(
`Rewrote incoming HTTP URL from "${beforeRewriteUrl}" to "${req.url}"`
);
}
} finally {
this.killBuilderDevServer(pid);
}
}
}
*/
for (const phase of phases) {
statusCode = undefined;
@@ -2269,11 +2360,12 @@ async function findBuildMatch(
if (!isIndex(match.src)) {
return match;
} else {
// if isIndex === true and ends in .html, we're done. Otherwise, keep searching
bestIndexMatch = match;
// If isIndex === true and ends in `.html`, we're done.
// Otherwise, keep searching.
if (extname(match.src) === '.html') {
return bestIndexMatch;
return match;
}
bestIndexMatch = match;
}
}
}
@@ -2295,6 +2387,13 @@ async function shouldServe(
config,
builderWithPkg: { builder },
} = match;
// "middleware" file is not served as a regular asset,
// instead it gets invoked as part of the routing logic.
if (config?.middleware === true) {
return false;
}
const cleanSrc = src.endsWith('.html') ? src.slice(0, -5) : src;
const trimmedPath = requestPath.endsWith('/')
? requestPath.slice(0, -1)

View File

@@ -0,0 +1 @@
export default () => new Response(null, { status: 500 });

View File

@@ -0,0 +1 @@
throw new Error('Middleware init error');

View File

@@ -0,0 +1,3 @@
export default () => {
throw new Error('Middleware handler error');
};

View File

@@ -0,0 +1,9 @@
export default req => {
const url = new URL(req.url);
return new Response(null, {
status: 302,
headers: {
location: `https://vercel.com${url.pathname}${url.search}`,
},
});
};

View File

@@ -0,0 +1 @@
export default () => new Response('hi from middleware');

View File

@@ -0,0 +1,5 @@
export default (req, res) => {
res.json({
url: req.url,
});
};

View File

@@ -0,0 +1,6 @@
export default () =>
new Response(null, {
headers: {
'x-middleware-rewrite': '/api/fn?from-middleware=true',
},
});

View File

@@ -0,0 +1 @@
<h1>Another</h1>

View File

@@ -0,0 +1 @@
<h1>Index</h1>

View File

@@ -0,0 +1,19 @@
export default req => {
const url = new URL(req.url);
if (url.pathname === '/') {
// Pass-through "index.html" page
return new Response(null, {
headers: {
'x-middleware-next': '1',
},
});
}
// Everything else goes to "another.html"
return new Response(null, {
headers: {
'x-middleware-rewrite': '/another.html',
},
});
};

View File

@@ -433,3 +433,75 @@ test(
await testPath(404, '/i-do-not-exist');
})
);
test(
'[vercel dev] Middleware that returns a 200 response',
testFixtureStdio('middleware-response', async (testPath: any) => {
await testPath(200, '/', 'hi from middleware');
await testPath(200, '/another', 'hi from middleware');
})
);
test(
'[vercel dev] Middleware that does basic rewrite',
testFixtureStdio('middleware-rewrite', async (testPath: any) => {
await testPath(200, '/', '<h1>Index</h1>');
await testPath(200, '/index', '<h1>Another</h1>');
await testPath(200, '/another', '<h1>Another</h1>');
await testPath(200, '/another.html', '<h1>Another</h1>');
await testPath(200, '/foo', '<h1>Another</h1>');
})
);
test(
'[vercel dev] Middleware that rewrites with custom query params',
testFixtureStdio('middleware-rewrite-query', async (testPath: any) => {
await testPath(200, '/?foo=bar', '{"url":"/?from-middleware=true"}');
await testPath(
200,
'/another?foo=bar',
'{"url":"/another?from-middleware=true"}'
);
await testPath(
200,
'/api/fn?foo=bar',
'{"url":"/api/fn?from-middleware=true"}'
);
})
);
test(
'[vercel dev] Middleware that redirects',
testFixtureStdio('middleware-redirect', async (testPath: any) => {
await testPath(302, '/', null, {
location: 'https://vercel.com/',
});
await testPath(302, '/home', null, {
location: 'https://vercel.com/home',
});
await testPath(302, '/?foo=bar', null, {
location: 'https://vercel.com/?foo=bar',
});
})
);
test(
'[vercel dev] Middleware with error in function handler',
testFixtureStdio('middleware-error-in-handler', async (testPath: any) => {
await testPath(500, '/', /EDGE_FUNCTION_INVOCATION_FAILED/);
})
);
test(
'[vercel dev] Middleware with error at init',
testFixtureStdio('middleware-error-at-init', async (testPath: any) => {
await testPath(500, '/', /EDGE_FUNCTION_INVOCATION_FAILED/);
})
);
test(
'[vercel dev] Middleware with an explicit 500 response',
testFixtureStdio('middleware-500-response', async (testPath: any) => {
await testPath(500, '/', /EDGE_FUNCTION_INVOCATION_FAILED/);
})
);

View File

@@ -74,7 +74,7 @@ import { Readable } from 'stream';
import type { Bridge } from '@vercel/node-bridge/bridge';
import { getVercelLauncher } from '@vercel/node-bridge/launcher.js';
import { VercelProxyResponse } from '@vercel/node-bridge/types';
import { streamToBuffer } from '@vercel/build-utils';
import { Config, streamToBuffer } from '@vercel/build-utils';
import exitHook from 'exit-hook';
import { EdgeRuntime, Primitives, runServer } from 'edge-runtime';
import { getConfig } from '@vercel/static-config';
@@ -187,6 +187,7 @@ async function createEdgeEventHandler(
return async function (request: IncomingMessage) {
const response = await fetch(server.url, {
redirect: 'manual',
method: 'post',
body: await serializeRequest(request),
});
@@ -214,10 +215,15 @@ function parseRuntime(entrypoint: string): string | undefined {
async function createEventHandler(
entrypoint: string,
config: Config,
options: { shouldAddHelpers: boolean }
): Promise<(request: IncomingMessage) => Promise<VercelProxyResponse>> {
const runtime = parseRuntime(entrypoint);
if (runtime === 'experimental-edge') {
// `middleware.js`/`middleware.ts` file is always run as
// an Edge Function, otherwise needs to be opted-in via
// `export const config = { runtime: 'experimental-edge' }`
if (config.middleware === true || runtime === 'experimental-edge') {
return createEdgeEventHandler(entrypoint);
}
@@ -241,7 +247,9 @@ async function main() {
await listen(proxyServer, 0, '127.0.0.1');
const entryPointPath = join(process.cwd(), entrypoint!);
handleEvent = await createEventHandler(entryPointPath, { shouldAddHelpers });
handleEvent = await createEventHandler(entryPointPath, config, {
shouldAddHelpers,
});
const address = proxyServer.address();
if (typeof process.send === 'function') {

View File

@@ -522,7 +522,7 @@ async function doTypeCheck(
{ entrypoint, workPath, meta = {} }: StartDevServerOptions,
projectTsConfig: string | null
): Promise<void> {
const { devCacheDir = join(workPath, '.now', 'cache') } = meta;
const { devCacheDir = join(workPath, '.vercel', 'cache') } = meta;
const entrypointCacheDir = join(devCacheDir, 'node', entrypoint);
// In order to type-check a single file, a standalone tsconfig