Skip to content

Commit 9d1dfdd

Browse files
committed
chore: change config shape
1 parent dc8c3de commit 9d1dfdd

19 files changed

+263
-29
lines changed

demos/default/pages/api/hello-background.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default (req, res) => {
55
}
66

77
export const config = {
8-
background: true,
8+
type: 'experimental-background',
99
}

demos/default/pages/api/hello-scheduled.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export default (req, res) => {
55
}
66

77
export const config = {
8+
type: 'experimental-scheduled',
89
schedule: '@hourly',
910
}

plugin/src/helpers/analysis.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,100 @@ import { relative } from 'path'
44
import { extractExportedConstValue, UnsupportedValueError } from 'next/dist/build/analysis/extract-const-value'
55
import { parseModule } from 'next/dist/build/analysis/parse-module'
66

7-
export interface ApiConfig {
8-
runtime?: 'node' | 'experimental-edge'
9-
background?: boolean
10-
schedule?: string
7+
export interface ApiStandardConfig {
8+
type?: never
9+
runtime?: 'nodejs' | 'experimental-edge'
10+
schedule?: never
1111
}
1212

13+
export interface ApiScheduledConfig {
14+
type: 'experimental-scheduled'
15+
runtime?: 'nodejs'
16+
schedule: string
17+
}
18+
19+
export interface ApiBackgroundConfig {
20+
type: 'experimental-background'
21+
runtime?: 'nodejs'
22+
schedule?: never
23+
}
24+
25+
export type ApiConfig = ApiStandardConfig | ApiScheduledConfig | ApiBackgroundConfig
26+
27+
export const validateConfigValue = (config: ApiConfig, apiFilePath: string): config is ApiConfig => {
28+
if (config.type === 'experimental-scheduled') {
29+
if (!config.schedule) {
30+
console.error(
31+
`Invalid config value in ${relative(
32+
process.cwd(),
33+
apiFilePath,
34+
)}: schedule is required when type is "experimental-scheduled"`,
35+
)
36+
return false
37+
}
38+
if ((config as ApiConfig).runtime === 'experimental-edge') {
39+
console.error(
40+
`Invalid config value in ${relative(
41+
process.cwd(),
42+
apiFilePath,
43+
)}: edge runtime is not supported for scheduled functions`,
44+
)
45+
return false
46+
}
47+
return true
48+
}
49+
50+
if (!config.type || config.type === 'experimental-background') {
51+
if (config.schedule) {
52+
console.error(
53+
`Invalid config value in ${relative(
54+
process.cwd(),
55+
apiFilePath,
56+
)}: schedule is not allowed unless type is "experimental-scheduled"`,
57+
)
58+
return false
59+
}
60+
if (config.type && (config as ApiConfig).runtime === 'experimental-edge') {
61+
console.error(
62+
`Invalid config value in ${relative(
63+
process.cwd(),
64+
apiFilePath,
65+
)}: edge runtime is not supported for background functions`,
66+
)
67+
return false
68+
}
69+
return true
70+
}
71+
console.error(
72+
`Invalid config value in ${relative(process.cwd(), apiFilePath)}: type ${
73+
(config as ApiConfig).type
74+
} is not supported`,
75+
)
76+
return false
77+
}
78+
79+
/**
80+
* Uses Next's swc static analysis to extract the config values from a file.
81+
*/
1382
export const extractConfigFromFile = async (apiFilePath: string): Promise<ApiConfig> => {
1483
const fileContent = await fs.promises.readFile(apiFilePath, 'utf8')
84+
// No need to parse if there's no "config"
1585
if (!fileContent.includes('config')) {
1686
return {}
1787
}
1888
const ast = await parseModule(apiFilePath, fileContent)
89+
90+
let config: ApiConfig
1991
try {
20-
return extractExportedConstValue(ast, 'config')
92+
config = extractExportedConstValue(ast, 'config')
2193
} catch (error) {
2294
if (error instanceof UnsupportedValueError) {
2395
console.warn(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`)
2496
}
2597
return {}
2698
}
99+
if (validateConfigValue(config, apiFilePath)) {
100+
return config
101+
}
102+
throw new Error(`Unsupported config value in ${relative(process.cwd(), apiFilePath)}`)
27103
}

plugin/src/helpers/files.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,9 @@ const getServerFile = (root: string, includeBase = true) => {
325325
return findModuleFromBase({ candidates, paths: [root] })
326326
}
327327

328+
/**
329+
* Find the source file for a given page route
330+
*/
328331
export const getSourceFileForPage = (page: string, root: string) => {
329332
for (const extension of ['ts', 'js']) {
330333
const file = join(root, `${page}.${extension}`)
@@ -334,6 +337,9 @@ export const getSourceFileForPage = (page: string, root: string) => {
334337
}
335338
}
336339

340+
/**
341+
* Reads the node file trace file for a given file, and resolves the dependencies
342+
*/
337343
export const getDependenciesOfFile = async (file: string) => {
338344
const nft = `${file}.nft.json`
339345
if (!existsSync(nft)) {

plugin/src/helpers/functions.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ export const generateFunctions = async (
3131
const publishDir = relative(functionDir, publish)
3232

3333
for (const { route, config, compiled } of apiRoutes) {
34-
const apiHandlerSource = await getApiHandler({ page: route, schedule: config.schedule })
35-
const functionName = getFunctionNameForPage(route, config.background)
34+
const apiHandlerSource = await getApiHandler({
35+
page: route,
36+
config,
37+
})
38+
const functionName = getFunctionNameForPage(route, config.type === 'experimental-background')
3639
await ensureDir(join(functionsDir, functionName))
3740
await writeFile(join(functionsDir, functionName, `${functionName}.js`), apiHandlerSource)
3841
await copyFile(bridgeFile, join(functionsDir, functionName, 'bridge.js'))
@@ -137,6 +140,9 @@ export const setupImageFunction = async ({
137140
}
138141
}
139142

143+
/**
144+
* Look for API routes, and extract the config from the source file.
145+
*/
140146
export const getApiRouteConfigs = async (publish: string, baseDir: string): Promise<Array<ApiRouteConfig>> => {
141147
const pages = await readJSON(join(publish, 'server', 'pages-manifest.json'))
142148
const apiRoutes = Object.keys(pages).filter((page) => page.startsWith('/api/'))

plugin/src/helpers/utils.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,11 @@ import { I18n } from './types'
1010

1111
const RESERVED_FILENAME = /[^\w_-]/g
1212

13-
//
14-
// // Replace catch-all, e.g., [...slug]
15-
// .replace(CATCH_ALL_REGEX, '/:$1/*')
16-
// // Replace optional catch-all, e.g., [[...slug]]
17-
// .replace(OPTIONAL_CATCH_ALL_REGEX, '/*')
18-
// // Replace dynamic parameters, e.g., [id]
19-
// .replace(DYNAMIC_PARAMETER_REGEX, '/:$1'),
20-
//
21-
13+
/**
14+
* Given a Next route, generates a valid Netlify function name.
15+
* If "background" is true then the function name will have `-background`
16+
* appended to it, meaning that it is executed as a background function.
17+
*/
2218
export const getFunctionNameForPage = (page: string, background = false) =>
2319
`${page
2420
.replace(CATCH_ALL_REGEX, '_$1-SPLAT')
@@ -141,13 +137,16 @@ export const getApiRewrites = (basePath: string, apiRoutes: Array<ApiRouteConfig
141137
const apiRewrites = apiRoutes.map((apiRoute) => {
142138
const [from] = toNetlifyRoute(`${basePath}${apiRoute.route}`)
143139

144-
// Scheduled functions can't be invoked directly
145-
if (apiRoute.config.schedule) {
140+
// Scheduled functions can't be invoked directly, so we 404 them.
141+
if (apiRoute.config.type === 'experimental-scheduled') {
146142
return { from, to: '/404.html', status: 404 }
147143
}
148144
return {
149145
from,
150-
to: `/.netlify/functions/${getFunctionNameForPage(apiRoute.route, apiRoute.config.background)}`,
146+
to: `/.netlify/functions/${getFunctionNameForPage(
147+
apiRoute.route,
148+
apiRoute.config.type === 'experimental-background',
149+
)}`,
151150
status: 200,
152151
}
153152
})

plugin/src/templates/getApiHandler.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Bridge as NodeBridge } from '@vercel/node-bridge/bridge'
33
// Aliasing like this means the editor may be able to syntax-highlight the string
44
import { outdent as javascript } from 'outdent'
55

6+
import { ApiConfig } from '../helpers/analysis'
67
import type { NextConfig } from '../helpers/config'
78

89
import type { NextServerType } from './handlerUtils'
@@ -111,21 +112,36 @@ const makeHandler = (conf: NextConfig, app, pageRoot, page) => {
111112
}
112113
}
113114

114-
export const getApiHandler = ({ page, schedule, publishDir = '../../../.next', appDir = '../../..' }): string =>
115+
/**
116+
* Handlers for API routes are simpler than page routes, but they each have a separate one
117+
*/
118+
export const getApiHandler = ({
119+
page,
120+
config,
121+
publishDir = '../../../.next',
122+
appDir = '../../..',
123+
}: {
124+
page: string
125+
config: ApiConfig
126+
publishDir?: string
127+
appDir?: string
128+
}): string =>
115129
// This is a string, but if you have the right editor plugin it should format as js
116130
javascript/* javascript */ `
117131
const { Server } = require("http");
118132
// We copy the file here rather than requiring from the node module
119133
const { Bridge } = require("./bridge");
120134
const { getMaxAge, getMultiValueHeaders, getNextServer } = require('./handlerUtils')
121135
122-
${schedule ? `const { schedule } = require("@netlify/functions")` : ''}
136+
${config.type === 'experimental-scheduled' ? `const { schedule } = require("@netlify/functions")` : ''}
123137
124138
125139
const { config } = require("${publishDir}/required-server-files.json")
126140
let staticManifest
127141
const path = require("path");
128142
const pageRoot = path.resolve(path.join(__dirname, "${publishDir}", "serverless", "pages"));
129143
const handler = (${makeHandler.toString()})(config, "${appDir}", pageRoot, ${JSON.stringify(page)})
130-
exports.handler = ${schedule ? `schedule(${JSON.stringify(schedule)}, handler);` : 'handler'}
144+
exports.handler = ${
145+
config.type === 'experimental-scheduled' ? `schedule(${JSON.stringify(config.schedule)}, handler);` : 'handler'
146+
}
131147
`

plugin/src/templates/getPageResolver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const getPageResolver = async ({ publish, target }: { publish: string; ta
3333
`
3434
}
3535

36+
/**
37+
* API routes only need the dependencies for a single entrypoint, so we use the
38+
* NFT trace file to get the dependencies.
39+
*/
3640
export const getSinglePageResolver = async ({
3741
functionsDir,
3842
sourceFile,

test/analysis.spec.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,24 @@ import { extractConfigFromFile } from '../plugin/src/helpers/analysis'
22
import { resolve } from 'path'
33
import { getDependenciesOfFile } from '../plugin/src/helpers/files'
44
describe('static source analysis', () => {
5+
beforeEach(() => {
6+
// Spy on console.error
7+
jest.spyOn(console, 'error').mockImplementation(() => {})
8+
})
9+
afterEach(() => {
10+
// Restore console.error
11+
;(console.error as jest.Mock).mockRestore()
12+
})
513
it('should extract config values from a source file', async () => {
614
const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background.js'))
715
expect(config).toEqual({
8-
background: true,
16+
type: 'experimental-background',
917
})
1018
})
1119
it('should extract config values from a TypeScript source file', async () => {
1220
const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background.ts'))
1321
expect(config).toEqual({
14-
background: true,
22+
type: 'experimental-background',
1523
})
1624
})
1725
it('should return an empty config if not defined', async () => {
@@ -27,9 +35,50 @@ describe('static source analysis', () => {
2735
it('should extract schedule values from a source file', async () => {
2836
const config = await extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/scheduled.ts'))
2937
expect(config).toEqual({
38+
type: 'experimental-scheduled',
3039
schedule: '@daily',
3140
})
3241
})
42+
it('should throw if schedule is provided when type is background', async () => {
43+
await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background-schedule.ts'))).rejects.toThrow(
44+
'Unsupported config value in test/fixtures/analysis/background-schedule.ts',
45+
)
46+
expect(console.error).toHaveBeenCalledWith(
47+
`Invalid config value in test/fixtures/analysis/background-schedule.ts: schedule is not allowed unless type is "experimental-scheduled"`,
48+
)
49+
})
50+
it('should throw if schedule is provided when type is default', async () => {
51+
await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/default-schedule.ts'))).rejects.toThrow(
52+
'Unsupported config value in test/fixtures/analysis/default-schedule.ts',
53+
)
54+
expect(console.error).toHaveBeenCalledWith(
55+
`Invalid config value in test/fixtures/analysis/default-schedule.ts: schedule is not allowed unless type is "experimental-scheduled"`,
56+
)
57+
})
58+
it('should throw if schedule is not provided when type is scheduled', async () => {
59+
await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/missing-schedule.ts'))).rejects.toThrow(
60+
'Unsupported config value in test/fixtures/analysis/missing-schedule.ts',
61+
)
62+
expect(console.error).toHaveBeenCalledWith(
63+
`Invalid config value in test/fixtures/analysis/missing-schedule.ts: schedule is required when type is "experimental-scheduled"`,
64+
)
65+
})
66+
it('should throw if edge runtime is specified for scheduled functions', async () => {
67+
await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/scheduled-edge.ts'))).rejects.toThrow(
68+
'Unsupported config value in test/fixtures/analysis/scheduled-edge.ts',
69+
)
70+
expect(console.error).toHaveBeenCalledWith(
71+
`Invalid config value in test/fixtures/analysis/scheduled-edge.ts: edge runtime is not supported for scheduled functions`,
72+
)
73+
})
74+
it('should throw if edge runtime is specified for background functions', async () => {
75+
await expect(extractConfigFromFile(resolve(__dirname, 'fixtures/analysis/background-edge.ts'))).rejects.toThrow(
76+
'Unsupported config value in test/fixtures/analysis/background-edge.ts',
77+
)
78+
expect(console.error).toHaveBeenCalledWith(
79+
`Invalid config value in test/fixtures/analysis/background-edge.ts: edge runtime is not supported for background functions`,
80+
)
81+
})
3382
})
3483

3584
describe('dependency tracing', () => {
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default (req, res) => {
2+
res.setHeader('Content-Type', 'application/json')
3+
res.status(200)
4+
res.json({ message: 'hello world :)' })
5+
}
6+
7+
export const config = {
8+
type: 'experimental-background',
9+
runtime: 'experimental-edge',
10+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default (req, res) => {
2+
res.setHeader('Content-Type', 'application/json')
3+
res.status(200)
4+
res.json({ message: 'hello world :)' })
5+
}
6+
7+
export const config = {
8+
type: 'experimental-background',
9+
// INVALID
10+
schedule: '@daily',
11+
}

test/fixtures/analysis/background.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default (req, res) => {
55
}
66

77
export const config = {
8-
background: true,
8+
type: 'experimental-background',
99
}

test/fixtures/analysis/background.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ export default (req, res) => {
55
}
66

77
export const config = {
8-
background: true,
8+
type: 'experimental-background',
99
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export default (req, res) => {
2+
res.setHeader('Content-Type', 'application/json')
3+
res.status(200)
4+
res.json({ message: 'hello world :)' })
5+
}
6+
7+
export const config = {
8+
// INVALID
9+
schedule: '@daily',
10+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export default (req, res) => {
2+
res.setHeader('Content-Type', 'application/json')
3+
res.status(200)
4+
res.json({ message: 'hello world :)' })
5+
}
6+
7+
export const config = {
8+
type: 'experimental-scheduled',
9+
}

0 commit comments

Comments
 (0)