[build-utils][cli][client][node][next][static-config]: forward crons from vercel.json to config.json (#9454)

This PR changes the way cron jobs are being created in the build output
API. This is my first time contributing here. If you see something
unusual, let me know.

 Good for review

Our goal is to:
- Allow creating cron jobs via the `crons` property of `vercel.json` for
end users
- Allow framework authors to create cron jobs on Vercel via the `crons`
property of the Build Output API configuration

---

As you can see, we removed the previous implementation where cron jobs
could be configured at the function code level (export const cron = ""),
on top of vercel.json `functions` property. Here's why:

- All frameworks would have to implement the configure at the function
code level
- Not all frameworks can easily map a path to a specific function
(example: SvelteKit) and would have to bail on bundling functions inside
the same lambda
- Configuring a path + scheduler provides a better mapping of what cron
jobs are as of today: API routes on a schedule and not functions on a
schedule
- Dynamic routes Cron Jobs will be supported:
/api/crons/sync-slack-team/230
- Query parameters will be supported support:
/api/crons/sync-slack-team/230?secret=32k13l2k13lk21 (= securing cron
jobs v0)
- 100% frameworks compatibility from day one

Next.js and other frameworks may choose to implement their own cron jobs
feature that will then need to be configured through the `crons`
property of `config.json` (build output API).

cc @timneutkens @Rich-Harris 

Internal thread:
https://vercel.slack.com/archives/C04DWF5HB6K/p1676366892714349
This commit is contained in:
Vincent Voyer
2023-02-16 11:49:09 +01:00
committed by GitHub
parent 1bb7b37e0c
commit 667af829c4
21 changed files with 153 additions and 155 deletions

View File

@@ -1,4 +1,4 @@
import type { Cron, Files, FunctionFramework } from './types';
import type { Files, FunctionFramework } from './types';
/**
* An Edge Functions output
@@ -41,9 +41,6 @@ export class EdgeFunction {
/** The regions where the edge function will be executed on */
regions?: string | string[];
/** Cronjob definition for the edge function */
cron?: Cron;
/** The framework */
framework?: FunctionFramework;
@@ -56,7 +53,6 @@ export class EdgeFunction {
this.envVarsInUse = params.envVarsInUse;
this.assets = params.assets;
this.regions = params.regions;
this.cron = params.cron;
this.framework = params.framework;
}
}

View File

@@ -5,7 +5,7 @@ import minimatch from 'minimatch';
import { readlink } from 'fs-extra';
import { isSymbolicLink, isDirectory } from './fs/download';
import streamToBuffer from './fs/stream-to-buffer';
import type { Files, Config, Cron, FunctionFramework } from './types';
import type { Files, Config, FunctionFramework } from './types';
interface Environment {
[key: string]: string;
@@ -25,7 +25,6 @@ export interface LambdaOptionsBase {
supportsWrapper?: boolean;
experimentalResponseStreaming?: boolean;
operationType?: string;
cron?: Cron;
framework?: FunctionFramework;
}
@@ -64,7 +63,6 @@ export class Lambda {
environment: Environment;
allowQuery?: string[];
regions?: string[];
cron?: Cron;
/**
* @deprecated Use `await lambda.createZip()` instead.
*/
@@ -83,7 +81,6 @@ export class Lambda {
environment = {},
allowQuery,
regions,
cron,
supportsMultiPayloads,
supportsWrapper,
experimentalResponseStreaming,
@@ -138,10 +135,6 @@ export class Lambda {
);
}
if (cron !== undefined) {
assert(typeof cron === 'string', '"cron" is not a string');
}
if (framework !== undefined) {
assert(typeof framework === 'object', '"framework" is not an object');
assert(
@@ -166,7 +159,6 @@ export class Lambda {
this.environment = environment;
this.allowQuery = allowQuery;
this.regions = regions;
this.cron = cron;
this.zipBuffer = 'zipBuffer' in opts ? opts.zipBuffer : undefined;
this.supportsMultiPayloads = supportsMultiPayloads;
this.supportsWrapper = supportsWrapper;
@@ -246,7 +238,7 @@ export async function getLambdaOptionsFromFunction({
sourceFile,
config,
}: GetLambdaOptionsFromFunctionOptions): Promise<
Pick<LambdaOptions, 'memory' | 'maxDuration' | 'cron'>
Pick<LambdaOptions, 'memory' | 'maxDuration'>
> {
if (config?.functions) {
for (const [pattern, fn] of Object.entries(config.functions)) {
@@ -254,7 +246,6 @@ export async function getLambdaOptionsFromFunction({
return {
memory: fn.memory,
maxDuration: fn.maxDuration,
cron: fn.cron,
};
}
}

View File

@@ -29,11 +29,6 @@ export const functionsSchema = {
type: 'string',
maxLength: 256,
},
cron: {
type: 'string',
minLength: 9,
maxLength: 256,
},
},
},
},

View File

@@ -319,7 +319,6 @@ export interface BuilderFunctions {
runtime?: string;
includeFiles?: string;
excludeFiles?: string;
cron?: Cron;
};
}
@@ -411,7 +410,11 @@ export interface BuildResultBuildOutput {
buildOutputPath: string;
}
export type Cron = string;
export interface Cron {
path: string;
schedule: string;
}
/** The framework which created the function */
export interface FunctionFramework {
slug: string;

View File

@@ -16,6 +16,7 @@ import {
BuildResultV2Typical,
BuildResultV3,
NowBuildError,
Cron,
} from '@vercel/build-utils';
import {
detectBuilders,
@@ -88,6 +89,7 @@ interface BuildOutputConfig {
framework?: {
version: string;
};
crons?: Cron[];
}
/**
@@ -623,6 +625,7 @@ async function doBuild(
});
const mergedImages = mergeImages(localConfig.images, buildResults.values());
const mergedCrons = mergeCrons(localConfig.crons, buildResults.values());
const mergedWildcard = mergeWildcard(buildResults.values());
const mergedOverrides: Record<string, PathOverride> =
overrides.length > 0 ? Object.assign({}, ...overrides) : undefined;
@@ -638,6 +641,7 @@ async function doBuild(
wildcard: mergedWildcard,
overrides: mergedOverrides,
framework,
crons: mergedCrons,
};
await fs.writeJSON(join(outputDir, 'config.json'), config, { spaces: 2 });
@@ -746,6 +750,18 @@ function mergeImages(
return images;
}
function mergeCrons(
crons: BuildOutputConfig['crons'],
buildResults: Iterable<BuildResult | BuildOutputConfig>
): BuildOutputConfig['crons'] {
for (const result of buildResults) {
if ('crons' in result && result.crons) {
crons = Object.assign({}, crons, result.crons);
}
}
return crons;
}
function mergeWildcard(
buildResults: Iterable<BuildResult | BuildOutputConfig>
): BuildResultV2Typical['wildcard'] {

View File

@@ -13,7 +13,6 @@ import {
Builder,
BuildResultV2,
BuildResultV3,
Cron,
File,
FileFsRef,
BuilderV2,
@@ -41,7 +40,6 @@ export const OUTPUT_DIR = join(VERCEL_DIR, 'output');
* An entry in the "functions" object in `vercel.json`.
*/
interface FunctionConfiguration {
cron?: Cron;
memory?: number;
maxDuration?: number;
}
@@ -372,14 +370,12 @@ async function writeLambda(
throw new Error('Malformed `Lambda` - no "files" present');
}
const cron = functionConfiguration?.cron ?? lambda.cron;
const memory = functionConfiguration?.memory ?? lambda.memory;
const maxDuration = functionConfiguration?.maxDuration ?? lambda.maxDuration;
const config = {
...lambda,
handler: normalizePath(lambda.handler),
cron,
memory,
maxDuration,
type: undefined,

View File

@@ -93,6 +93,29 @@ const imagesSchema = {
},
};
const cronsSchema = {
type: 'array',
minItems: 0,
items: {
type: 'object',
additionalProperties: false,
required: ['path', 'schedule'],
properties: {
path: {
type: 'string',
minLength: 1,
maxLength: 512,
pattern: '^/.*',
},
schedule: {
type: 'string',
minLength: 9,
maxLength: 256,
},
},
},
};
const vercelConfigSchema = {
type: 'object',
// These are not all possibilities because `vc dev`
@@ -108,6 +131,7 @@ const vercelConfigSchema = {
trailingSlash: trailingSlashSchema,
functions: functionsSchema,
images: imagesSchema,
crons: cronsSchema,
},
};

View File

@@ -1,3 +1,3 @@
export default function (req, res) {
res.json('hello from the edge');
res.send('Hello from cron job!');
}

View File

@@ -1,17 +0,0 @@
export const config = {
runtime: 'edge',
cron: '* * * * *',
};
export default async function edge(request, event) {
const requestBody = await request.text();
return new Response(
JSON.stringify({
headerContentType: request.headers.get('content-type'),
url: request.url,
method: request.method,
body: requestBody,
})
);
}

View File

@@ -1,3 +0,0 @@
export default function (req, res) {
res.end('serverless says hello');
}

View File

@@ -1,7 +0,0 @@
export default function (req, res) {
res.json({ memory: parseInt(process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE) });
}
export const config = {
cron: '* * * * *',
};

View File

@@ -1,10 +1,8 @@
{
"functions": {
"api/overwrite/serverless.js": {
"cron": "0 10-20 * * *"
},
"api/overwrite/edge.js": {
"cron": "10 * * * *"
"crons": [
{
"path": "/api/cron-job",
"schedule": "0 0 * * *"
}
}
]
}

View File

@@ -2635,7 +2635,7 @@ test('next unsupported functions config shows warning link', async t => {
t.is(output.exitCode, 0, formatOutput(output));
t.regex(
output.stderr,
/Ignoring function property `runtime`\. When using Next\.js, only `memory`, `maxDuration`, and `cron` can be used\./gm,
/Ignoring function property `runtime`\. When using Next\.js, only `memory` and `maxDuration` can be used\./gm,
formatOutput(output)
);
t.regex(

View File

@@ -1104,32 +1104,20 @@ describe('build', () => {
it('should include crons property in build output', async () => {
const cwd = fixture('with-cron');
const output = join(cwd, '.vercel', 'output', 'functions', 'api');
const output = join(cwd, '.vercel', 'output');
try {
process.chdir(cwd);
const exitCode = await build(client);
expect(exitCode).toBe(0);
const edge = await fs.readJSON(
join(output, 'edge.func', '.vc-config.json')
);
expect(edge).toHaveProperty('cron', '* * * * *');
const serverless = await fs.readJSON(
join(output, 'serverless.func', '.vc-config.json')
);
expect(serverless).toHaveProperty('cron', '* * * * *');
const overwriteServerless = await fs.readJSON(
join(output, 'overwrite', 'serverless.func', '.vc-config.json')
);
expect(overwriteServerless).toHaveProperty('cron', '0 10-20 * * *');
const overwriteEdge = await fs.readJSON(
join(output, 'overwrite', 'edge.func', '.vc-config.json')
);
expect(overwriteEdge).toHaveProperty('cron', '10 * * * *');
const config = await fs.readJSON(join(output, 'config.json'));
expect(config).toHaveProperty('crons', [
{
path: '/api/cron-job',
schedule: '0 0 * * *',
},
]);
} finally {
process.chdir(originalCwd);
delete process.env.__VERCEL_BUILD_RUNNING;

View File

@@ -285,4 +285,90 @@ describe('validateConfig', () => {
expect(error!.link).toEqual('https://vercel.link/functions-and-builds');
});
it('should error when crons have missing schedule', () => {
const error = validateConfig({
// @ts-ignore
crons: [{ path: '/api/test.js' }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0]` missing required property `schedule`.'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
it('should error when crons have missing path', () => {
const error = validateConfig({
// @ts-ignore
crons: [{ schedule: '* * * * *' }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0]` missing required property `path`.'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
it('should error when path is too long', () => {
const error = validateConfig({
crons: [{ path: '/' + 'x'.repeat(512), schedule: '* * * * *' }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0].path` should NOT be longer than 512 characters.'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
it('should error when schedule is too long', () => {
const error = validateConfig({
crons: [{ path: '/', schedule: '*'.repeat(257) }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0].schedule` should NOT be longer than 256 characters.'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
it('should error when path is empty', () => {
const error = validateConfig({
crons: [{ path: '', schedule: '* * * * *' }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0].path` should NOT be shorter than 1 characters.'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
it('should error when schedule is too short', () => {
const error = validateConfig({
crons: [{ path: '/', schedule: '* * * * ' }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0].schedule` should NOT be shorter than 9 characters.'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
it("should error when path doesn't start with `/`", () => {
const error = validateConfig({
crons: [{ path: 'api/cron', schedule: '* * * * *' }],
});
expect(error!.message).toEqual(
'Invalid vercel.json - `crons[0].path` should match pattern "^/.*".'
);
expect(error!.link).toEqual(
'https://vercel.com/docs/concepts/projects/project-configuration#crons'
);
});
});

View File

@@ -3,6 +3,7 @@ import type {
BuilderFunctions,
Images,
ProjectSettings,
Cron,
} from '@vercel/build-utils';
import type { Header, Route, Redirect, Rewrite } from '@vercel/routing-utils';
@@ -154,6 +155,7 @@ export interface VercelConfig {
framework?: string | null;
outputDirectory?: string | null;
images?: Images;
crons?: Cron[];
}
export interface GitMetadata {

View File

@@ -873,7 +873,6 @@ export async function serverBuild({
runtime: nodeVersion.runtime,
maxDuration: group.maxDuration,
isStreaming: group.isStreaming,
cron: group.cron,
nextVersion,
});

View File

@@ -15,7 +15,6 @@ import {
NodejsLambda,
EdgeFunction,
Images,
Cron,
} from '@vercel/build-utils';
import { NodeFileTraceReasons } from '@vercel/nft';
import type {
@@ -1311,7 +1310,6 @@ export function addLocaleOrDefault(
export type LambdaGroup = {
pages: string[];
memory?: number;
cron?: Cron;
maxDuration?: number;
isStreaming?: boolean;
isPrerenders?: boolean;
@@ -1364,7 +1362,7 @@ export async function getPageLambdaGroups({
const routeName = normalizePage(page.replace(/\.js$/, ''));
const isPrerenderRoute = prerenderRoutes.has(routeName);
let opts: { memory?: number; maxDuration?: number; cron?: Cron } = {};
let opts: { memory?: number; maxDuration?: number } = {};
if (config && config.functions) {
const sourceFile = await getSourceFilePathFromPage({
@@ -1382,8 +1380,7 @@ export async function getPageLambdaGroups({
const matches =
group.maxDuration === opts.maxDuration &&
group.memory === opts.memory &&
group.isPrerenders === isPrerenderRoute &&
!opts.cron; // Functions with a cronjob must be on their own
group.isPrerenders === isPrerenderRoute;
if (matches) {
let newTracedFilesSize = group.pseudoLayerBytes;
@@ -2318,7 +2315,6 @@ interface EdgeFunctionMatcher {
}
export async function getMiddlewareBundle({
config = {},
entryPath,
outputDirectory,
routesManifest,
@@ -2383,21 +2379,6 @@ export async function getMiddlewareBundle({
edgeFunction.wasm
);
const edgeFunctionOptions: { cron?: Cron } = {};
if (config.functions) {
const sourceFile = await getSourceFilePathFromPage({
workPath: entryPath,
page: `${edgeFunction.page}.js`,
});
const opts = await getLambdaOptionsFromFunction({
sourceFile,
config,
});
edgeFunctionOptions.cron = opts.cron;
}
return {
type,
page: edgeFunction.page,
@@ -2442,7 +2423,6 @@ export async function getMiddlewareBundle({
);
return new EdgeFunction({
...edgeFunctionOptions,
deploymentTarget: 'v8-worker',
name: edgeFunction.name,
files: {

View File

@@ -1,44 +0,0 @@
const path = require('node:path');
const fs = require('fs-extra');
const { build } = require('../../dist');
function getFixture(name) {
return path.join(__dirname, 'fixtures', name);
}
const initialCorepackValue = process.env.COREPACK_ENABLE_STRICT;
beforeEach(() => {
process.env.COREPACK_ENABLE_STRICT = '0';
});
afterEach(() => {
process.env.COREPACK_ENABLE_STRICT = initialCorepackValue;
});
it('should include cron property from config', async () => {
const cwd = getFixture('03-with-api-routes');
await fs.remove(path.join(cwd, '.next'));
const result = await build({
workPath: cwd,
repoRootPath: cwd,
entrypoint: 'package.json',
config: {
functions: {
'pages/api/edge.js': {
cron: '* * * * *',
},
'pages/api/serverless.js': {
cron: '* * * * *',
},
},
},
meta: {
skipDownload: true,
},
});
expect(result.output['api/serverless']).toHaveProperty('cron', '* * * * *');
expect(result.output['api/edge']).toHaveProperty('cron', '* * * * *');
});

View File

@@ -424,8 +424,6 @@ export const build: BuildV3 = async ({
isEdgeFunction = isEdgeRuntime(staticConfig.runtime);
}
const cron = staticConfig?.cron;
debug('Tracing input files...');
const traceTime = Date.now();
const { preparedFiles, shouldAddSourcemapSupport } = await compile(
@@ -475,7 +473,6 @@ export const build: BuildV3 = async ({
// TODO: remove - these two properties should not be required
name: outputPath,
deploymentTarget: 'v8-worker',
cron,
});
} else {
// "nodejs" runtime is the default
@@ -494,7 +491,6 @@ export const build: BuildV3 = async ({
shouldAddSourcemapSupport,
awsLambdaHandler,
experimentalResponseStreaming,
cron,
});
}

View File

@@ -14,7 +14,6 @@ export const BaseFunctionConfigSchema = {
type: 'object',
properties: {
runtime: { type: 'string' },
cron: { type: 'string' },
memory: { type: 'number' },
maxDuration: { type: 'number' },
regions: {