netlify-cli
Version:
Netlify command line tool
292 lines • 14.4 kB
JavaScript
import { Buffer } from 'buffer';
import { promises as fs } from 'fs';
import path from 'path';
import express from 'express';
import expressLogging from 'express-logging';
import { jwtDecode } from 'jwt-decode';
import { NETLIFYDEVLOG, log } from '../../utils/command-helpers.js';
import { UNLINKED_SITE_MOCK_ID } from '../../utils/dev.js';
import { isFeatureFlagEnabled } from '../../utils/feature-flags.js';
import { CLOCKWORK_USERAGENT, getFunctionsDistPath, getFunctionsServePath, getInternalFunctionsDir, } from '../../utils/functions/index.js';
import { NFFunctionName, NFFunctionRoute } from '../../utils/headers.js';
import { headers as efHeaders } from '../edge-functions/headers.js';
import { getGeoLocation } from '../geo-location.js';
import { handleBackgroundFunction, handleBackgroundFunctionResult } from './background.js';
import { createFormSubmissionHandler } from './form-submissions-handler.js';
import { FunctionsRegistry } from './registry.js';
import { handleScheduledFunction } from './scheduled.js';
import { handleSynchronousFunction } from './synchronous.js';
import { shouldBase64Encode } from './utils.js';
const buildClientContext = function (headers) {
// inject a client context based on auth header, ported over from netlify-lambda (https://github.com/netlify/netlify-lambda/pull/57)
if (!headers.authorization)
return;
const parts = headers.authorization.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer')
return;
const identity = {
url: 'https://netlify-dev-locally-emulated-identity.netlify.app/.netlify/identity',
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJuZXRsaWZ5IGRldiIsInRlc3REYXRhIjoiTkVUTElGWV9ERVZfTE9DQUxMWV9FTVVMQVRFRF9JREVOVElUWSJ9.2eSDqUOZAOBsx39FHFePjYj12k0LrxldvGnlvDu3GMI',
// you can decode this with https://jwt.io/
// just says
// {
// "source": "netlify dev",
// "testData": "NETLIFY_DEV_LOCALLY_EMULATED_IDENTITY"
// }
};
try {
// This data is available on both the context root and under custom.netlify for retro-compatibility.
// In the future it will only be available in custom.netlify.
const user = jwtDecode(parts[1]);
const netlifyContext = JSON.stringify({
identity,
user,
});
return {
identity,
user,
custom: {
netlify: Buffer.from(netlifyContext).toString('base64'),
},
};
}
catch {
// Ignore errors - bearer token is not a JWT, probably not intended for us
}
};
const hasBody = (req) =>
// copied from is-type package
(req.header('transfer-encoding') !== undefined || !Number.isNaN(Number(req.header('content-length')))) &&
// we expect a string or a buffer, because we use the two bodyParsers(text, raw) from express
(typeof req.body === 'string' || Buffer.isBuffer(req.body));
export const createHandler = function (options) {
const { functionsRegistry } = options;
return async function handler(request, response) {
// If these headers are set, it means we've already matched a function and we
// can just grab its name directly. We delete the header from the request
// because we don't want to expose it to user code.
let functionName = request.header(NFFunctionName);
delete request.headers[NFFunctionName];
const functionRoute = request.header(NFFunctionRoute);
delete request.headers[NFFunctionRoute];
// If there's still no function found, we check the functionsRegistry again.
// This is needed for the functions:serve command, where the dev server that normally does the matching doesn't run.
// It also matches the default URL (.netlify/functions/builders)
if (!functionName) {
const match = await functionsRegistry.getFunctionForURLPath(request.url, request.method,
// we're pretending there's no static file at the same URL.
// This is wrong, but in local dev we already did the matching
// in a downstream server where we had access to the file system, so this never hits.
() => Promise.resolve(false));
functionName = match?.func?.name;
}
const func = functionsRegistry.get(functionName ?? '');
if (func === undefined) {
response.statusCode = 404;
response.end('Function not found...');
return;
}
// Technically it follows from `func.hasValidName()` that `functionName != null`, but TS doesn't know that.
if (!func.hasValidName() || functionName == null) {
response.statusCode = 400;
response.end('Function name should consist only of alphanumeric characters, hyphen & underscores.');
return;
}
const isBase64Encoded = shouldBase64Encode(request.header('content-type'));
let body;
if (hasBody(request)) {
body = request.body.toString(isBase64Encoded ? 'base64' : 'utf8');
}
let remoteAddress = request.header('x-forwarded-for') || request.connection.remoteAddress || '';
remoteAddress =
remoteAddress
.split(remoteAddress.includes('.') ? ':' : ',')
.pop()
?.trim() ?? '';
const requestPath = request.header('x-netlify-original-pathname') ?? request.path;
delete request.headers['x-netlify-original-pathname'];
let requestQuery = request.query;
if (request.header('x-netlify-original-search')) {
const newRequestQuery = {};
const searchParams = new URLSearchParams(request.header('x-netlify-original-search'));
for (const key of searchParams.keys()) {
newRequestQuery[key] = searchParams.getAll(key);
}
requestQuery = newRequestQuery;
delete request.headers['x-netlify-original-search'];
}
const queryParamsAsMultiValue = Object.entries(requestQuery).reduce((prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }), {});
const geoLocation = await getGeoLocation({ ...options, mode: options.geolocationMode });
const multiValueHeaders = Object.entries({
...request.headers,
'client-ip': [remoteAddress],
'x-nf-client-connection-ip': [remoteAddress],
'x-nf-account-id': [options.accountId],
'x-nf-site-id': [options.siteInfo?.id ?? UNLINKED_SITE_MOCK_ID],
[efHeaders.Geo]: Buffer.from(JSON.stringify(geoLocation)).toString('base64'),
}).reduce((prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }), {});
const rawQuery = new URL(request.originalUrl, 'http://example.com').search.slice(1);
// TODO(serhalp): Update several tests to pass realistic `config` objects and remove nullish coalescing.
const protocol = options.config?.dev?.https ? 'https' : 'http';
const url = new URL(requestPath, `${protocol}://${request.get('host') || 'localhost'}`);
url.search = rawQuery;
const rawUrl = url.toString();
const event = {
path: requestPath,
httpMethod: request.method,
queryStringParameters: Object.entries(queryParamsAsMultiValue).reduce((prev, [key, value]) => ({ ...prev, [key]: value.join(', ') }), {}),
multiValueQueryStringParameters: queryParamsAsMultiValue,
headers: Object.entries(multiValueHeaders).reduce((prev, [key, value]) => ({ ...prev, [key]: value.join(', ') }), {}),
multiValueHeaders,
body,
isBase64Encoded,
rawUrl,
rawQuery,
route: functionRoute,
};
const clientContext = buildClientContext(request.headers) || {};
if (func.isBackground) {
handleBackgroundFunction(functionName, response);
// background functions do not receive a clientContext
const { error } = await func.invoke(event);
handleBackgroundFunctionResult(functionName, error);
}
else if (await func.isScheduled()) {
// In production, scheduled functions always receive POST requests, so we
// have to emulate that here, even if a user has triggered a GET request
// as part of their tests. If we don't do this, we'll hit problems when
// we send the invocation body in a request that can't have a body.
event.httpMethod = 'POST';
const { error, result } = await func.invoke({
...event,
body: JSON.stringify({
next_run: await func.getNextRun(),
}),
isBase64Encoded: false,
headers: {
...event.headers,
'user-agent': CLOCKWORK_USERAGENT,
'X-NF-Event': 'schedule',
},
}, clientContext);
handleScheduledFunction({
error,
request,
response,
// When we handle the result of invoking a scheduled function, we'll warn
// people in case their function returned a body or headers, since those
// will have no practical effect in production. However, in v2 functions
// we don't currently have a good way of asserting whether the body we're
// seeing has been actually produced by user code or by the bootstrap, so
// we risk printing that warn unnecessarily, which causes more harm than
// good. Until we find a way of making this detection better, ignore the
// invocation result entirely for v2 functions.
result: func.runtimeAPIVersion === 1 ? result : {},
});
}
else {
const { error, result } = await func.invoke(event, clientContext);
// check for existence of metadata if this is a builder function
// @ts-expect-error(serhalp) -- Investigate. There doesn't appear to be such a thing as `metadata`?
if (/^\/.netlify\/(builders)/.test(request.path) && !result?.metadata?.builder_function) {
response.status(400).send({
message: 'Function is not an on-demand builder. See https://ntl.fyi/create-builder for how to convert a function to a builder.',
});
response.end();
return;
}
await handleSynchronousFunction({ error, functionName: func.name, result, request, response });
}
};
};
const getFunctionsServer = (options) => {
const { functionsRegistry, siteUrl } = options;
const app = express();
const functionHandler = createHandler(options);
app.set('query parser', 'simple');
app.use(express.text({
limit: '6mb',
type: ['text/*', 'application/json'],
}));
app.use(express.raw({ limit: '6mb', type: '*/*' }));
app.use(createFormSubmissionHandler({ functionsRegistry, siteUrl }));
app.use(expressLogging(console, {
blacklist: ['/favicon.ico'],
}));
app.all('*', functionHandler);
return app;
};
export const startFunctionsServer = async (options) => {
const { blobsContext, capabilities, command, config, debug, loadDistFunctions, settings, site, siteInfo, siteUrl, timeouts, } = options;
const internalFunctionsDir = await getInternalFunctionsDir({ base: site.root, packagePath: command.workspacePackage });
const functionsDirectories = [];
let manifest;
// If the `loadDistFunctions` parameter is sent, the functions server will
// use the built functions created by zip-it-and-ship-it rather than building
// them from source.
if (loadDistFunctions) {
const distPath = await getFunctionsDistPath({ base: site.root, packagePath: command.workspacePackage });
if (distPath) {
functionsDirectories.push(distPath);
// When using built functions, read the manifest file so that we can
// extract metadata such as routes and API version.
try {
const manifestPath = path.join(distPath, 'manifest.json');
const data = await fs.readFile(manifestPath, 'utf8');
manifest = JSON.parse(data);
}
catch {
// no-op
}
}
}
else {
// The order of the function directories matters. Rightmost directories take
// precedence.
const sourceDirectories = [
internalFunctionsDir,
command.netlify.frameworksAPIPaths.functions.path,
settings.functions,
].filter((x) => x != null);
functionsDirectories.push(...sourceDirectories);
}
try {
const functionsServePath = getFunctionsServePath({ base: site.root, packagePath: command.workspacePackage });
await fs.rm(functionsServePath, { force: true, recursive: true });
}
catch {
// no-op
}
if (functionsDirectories.length === 0) {
return;
}
const functionsRegistry = new FunctionsRegistry({
blobsContext,
capabilities,
config,
debug,
frameworksAPIPaths: command.netlify.frameworksAPIPaths,
isConnected: Boolean(siteUrl),
logLambdaCompat: isFeatureFlagEnabled('cli_log_lambda_compat', siteInfo),
manifest,
// functions always need to be inside the packagePath if set inside a monorepo
projectRoot: command.workingDir,
settings,
timeouts,
});
await functionsRegistry.scan(functionsDirectories);
const server = getFunctionsServer({ ...options, functionsRegistry });
await startWebServer({ server, settings, debug });
return functionsRegistry;
};
const startWebServer = async ({ debug, server, settings, }) => {
await new Promise((resolve) => {
server.listen(settings.functionsPort, () => {
if (debug) {
log(`${NETLIFYDEVLOG} Functions server is listening on ${settings.functionsPort.toString()}`);
}
resolve();
});
});
};
//# sourceMappingURL=server.js.map