mirror of
https://github.com/LukeHagar/vercel.git
synced 2025-12-09 21:07:46 +00:00
[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:
@@ -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));
|
||||
|
||||
18
packages/cli/src/util/dev/headers.ts
Normal file
18
packages/cli/src/util/dev/headers.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default () => new Response(null, { status: 500 });
|
||||
@@ -0,0 +1 @@
|
||||
throw new Error('Middleware init error');
|
||||
@@ -0,0 +1,3 @@
|
||||
export default () => {
|
||||
throw new Error('Middleware handler error');
|
||||
};
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export default () => new Response('hi from middleware');
|
||||
@@ -0,0 +1,5 @@
|
||||
export default (req, res) => {
|
||||
res.json({
|
||||
url: req.url,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export default () =>
|
||||
new Response(null, {
|
||||
headers: {
|
||||
'x-middleware-rewrite': '/api/fn?from-middleware=true',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<h1>Another</h1>
|
||||
@@ -0,0 +1 @@
|
||||
<h1>Index</h1>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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/);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user