Skip to content

Commit

Permalink
feat: support preferStatic for v2 functions (#6211)
Browse files Browse the repository at this point in the history
* feat: support `preferStatic` for v2 functions

* refactor: move logic into function matching

* refactor: typescriptify!

* chore: annotate that type needs fixing
  • Loading branch information
Skn0tt authored Nov 28, 2023
1 parent c9dcf30 commit 6bd29e0
Show file tree
Hide file tree
Showing 6 changed files with 63 additions and 66 deletions.
105 changes: 44 additions & 61 deletions src/lib/functions/netlify-function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,48 @@ import semver from 'semver'

import { error as errorExit } from '../../utils/command-helpers.js'
import { BACKGROUND } from '../../utils/functions/get-functions.js'
import type { BlobsContext } from '../blobs/blobs.js'

const TYPESCRIPT_EXTENSIONS = new Set(['.cts', '.mts', '.ts'])
const V2_MIN_NODE_VERSION = '18.14.0'

// Returns a new set with all elements of `setA` that don't exist in `setB`.
// @ts-expect-error TS(7006) FIXME: Parameter 'setA' implicitly has an 'any' type.
const difference = (setA, setB) => new Set([...setA].filter((item) => !setB.has(item)))
const difference = (setA: Set<string>, setB: Set<string>) => new Set([...setA].filter((item) => !setB.has(item)))

// @ts-expect-error TS(7006) FIXME: Parameter 'schedule' implicitly has an 'any' type.
const getNextRun = function (schedule) {
const getNextRun = function (schedule: string) {
const cron = CronParser.parseExpression(schedule, {
tz: 'Etc/UTC',
})
return cron.next().toDate()
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type $FIXME = any

export default class NetlifyFunction {
public readonly name: string
public readonly mainFile: string
public readonly displayName: string
public readonly schedule?: string

private readonly directory: string
private readonly projectRoot: string
private readonly blobsContext: BlobsContext
private readonly timeoutBackground: number
private readonly timeoutSynchronous: number

// Determines whether this is a background function based on the function
// name.
private readonly isBackground: boolean

private buildQueue?: Promise<$FIXME>
private buildData?: $FIXME
private buildError: unknown | null = null

// List of the function's source files. This starts out as an empty set
// and will get populated on every build.
private readonly srcFiles = new Set<string>()

constructor({
// @ts-expect-error TS(7031) FIXME: Binding element 'blobsContext' implicitly has an '... Remove this comment to see the full error message
blobsContext,
Expand All @@ -50,65 +73,43 @@ export default class NetlifyFunction {
// @ts-expect-error TS(7031) FIXME: Binding element 'timeoutSynchronous' implicitly ha... Remove this comment to see the full error message
timeoutSynchronous,
}) {
// @ts-expect-error TS(2339) FIXME: Property 'blobsContext' does not exist on type 'Ne... Remove this comment to see the full error message
this.blobsContext = blobsContext
// @ts-expect-error TS(2339) FIXME: Property 'buildError' does not exist on type 'Netl... Remove this comment to see the full error message
this.buildError = null
// @ts-expect-error TS(2339) FIXME: Property 'config' does not exist on type 'NetlifyF... Remove this comment to see the full error message
this.config = config
// @ts-expect-error TS(2339) FIXME: Property 'directory' does not exist on type 'Netli... Remove this comment to see the full error message
this.directory = directory
// @ts-expect-error TS(2339) FIXME: Property 'errorExit' does not exist on type 'Netli... Remove this comment to see the full error message
this.errorExit = errorExit
this.mainFile = mainFile
this.name = name
// @ts-expect-error TS(2339) FIXME: Property 'displayName' does not exist on type 'Net... Remove this comment to see the full error message
this.displayName = displayName ?? name
// @ts-expect-error TS(2339) FIXME: Property 'projectRoot' does not exist on type 'Net... Remove this comment to see the full error message
this.projectRoot = projectRoot
// @ts-expect-error TS(2339) FIXME: Property 'runtime' does not exist on type 'Netlify... Remove this comment to see the full error message
this.runtime = runtime
// @ts-expect-error TS(2339) FIXME: Property 'timeoutBackground' does not exist on typ... Remove this comment to see the full error message
this.timeoutBackground = timeoutBackground
// @ts-expect-error TS(2339) FIXME: Property 'timeoutSynchronous' does not exist on ty... Remove this comment to see the full error message
this.timeoutSynchronous = timeoutSynchronous
// @ts-expect-error TS(2339) FIXME: Property 'settings' does not exist on type 'Netlif... Remove this comment to see the full error message
this.settings = settings

// Determines whether this is a background function based on the function
// name.
// @ts-expect-error TS(2339) FIXME: Property 'isBackground' does not exist on type 'Ne... Remove this comment to see the full error message
this.isBackground = name.endsWith(BACKGROUND)

const functionConfig = config.functions && config.functions[name]
// @ts-expect-error TS(2339) FIXME: Property 'schedule' does not exist on type 'Netlif... Remove this comment to see the full error message
this.schedule = functionConfig && functionConfig.schedule

// List of the function's source files. This starts out as an empty set
// and will get populated on every build.
// @ts-expect-error TS(2339) FIXME: Property 'srcFiles' does not exist on type 'Netlif... Remove this comment to see the full error message
this.srcFiles = new Set()
}

get filename() {
// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
if (!this.buildData?.mainFile) {
return null
}

// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
return basename(this.buildData.mainFile)
}

getRecommendedExtension() {
// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
if (this.buildData?.runtimeAPIVersion !== 2) {
return
}

// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
const extension = this.buildData?.mainFile ? extname(this.buildData.mainFile) : undefined
// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
const moduleFormat = this.buildData?.outputModuleFormat

if (moduleFormat === 'esm') {
Expand All @@ -131,15 +132,12 @@ export default class NetlifyFunction {
}

async isScheduled() {
// @ts-expect-error TS(2339) FIXME: Property 'buildQueue' does not exist on type 'Netl... Remove this comment to see the full error message
await this.buildQueue

// @ts-expect-error TS(2339) FIXME: Property 'schedule' does not exist on type 'Netlif... Remove this comment to see the full error message
return Boolean(this.schedule)
}

isSupported() {
// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
return !(this.buildData?.runtimeAPIVersion === 2 && semver.lt(nodeVersion, V2_MIN_NODE_VERSION))
}

Expand All @@ -156,8 +154,8 @@ export default class NetlifyFunction {
return null
}

// @ts-expect-error TS(2339) FIXME: Property 'schedule' does not exist on type 'Netlif... Remove this comment to see the full error message
return getNextRun(this.schedule)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return getNextRun(this.schedule!)
}

// The `build` method transforms source files into invocable functions. Its
Expand All @@ -171,27 +169,20 @@ export default class NetlifyFunction {
const buildFunction = await this.runtime.getBuildFunction({
// @ts-expect-error TS(2339) FIXME: Property 'config' does not exist on type 'NetlifyF... Remove this comment to see the full error message
config: this.config,
// @ts-expect-error TS(2339) FIXME: Property 'directory' does not exist on type 'Netli... Remove this comment to see the full error message
directory: this.directory,
// @ts-expect-error TS(2339) FIXME: Property 'errorExit' does not exist on type 'Netli... Remove this comment to see the full error message
errorExit: this.errorExit,
errorExit,
func: this,
// @ts-expect-error TS(2339) FIXME: Property 'projectRoot' does not exist on type 'Net... Remove this comment to see the full error message
projectRoot: this.projectRoot,
})

// @ts-expect-error TS(2339) FIXME: Property 'buildQueue' does not exist on type 'Netl... Remove this comment to see the full error message
this.buildQueue = buildFunction({ cache })

try {
// @ts-expect-error TS(2339) FIXME: Property 'buildQueue' does not exist on type 'Netl... Remove this comment to see the full error message
const { includedFiles = [], schedule, srcFiles, ...buildData } = await this.buildQueue
const srcFilesSet = new Set(srcFiles)
const srcFilesSet = new Set<string>(srcFiles)
const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet)

// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
this.buildData = buildData
// @ts-expect-error TS(2339) FIXME: Property 'buildError' does not exist on type 'Netl... Remove this comment to see the full error message
this.buildError = null
// @ts-expect-error TS(2339) FIXME: Property 'srcFiles' does not exist on type 'Netlif... Remove this comment to see the full error message
this.srcFiles = srcFilesSet
Expand All @@ -208,28 +199,22 @@ export default class NetlifyFunction {

return { includedFiles, srcFilesDiff }
} catch (error) {
// @ts-expect-error TS(2339) FIXME: Property 'buildError' does not exist on type 'Netl... Remove this comment to see the full error message
this.buildError = error

return { error }
}
}

async getBuildData() {
// @ts-expect-error TS(2339) FIXME: Property 'buildQueue' does not exist on type 'Netl... Remove this comment to see the full error message
await this.buildQueue

// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
return this.buildData
}

// Compares a new set of source files against a previous one, returning an
// object with two Sets, one with added and the other with deleted files.
// @ts-expect-error TS(7006) FIXME: Parameter 'newSrcFiles' implicitly has an 'any' ty... Remove this comment to see the full error message
getSrcFilesDiff(newSrcFiles) {
// @ts-expect-error TS(2339) FIXME: Property 'srcFiles' does not exist on type 'Netlif... Remove this comment to see the full error message
getSrcFilesDiff(newSrcFiles: Set<string>) {
const added = difference(newSrcFiles, this.srcFiles)
// @ts-expect-error TS(2339) FIXME: Property 'srcFiles' does not exist on type 'Netlif... Remove this comment to see the full error message
const deleted = difference(this.srcFiles, newSrcFiles)

return {
Expand All @@ -240,25 +225,19 @@ export default class NetlifyFunction {

// Invokes the function and returns its response object.
async invoke(event = {}, context = {}) {
// @ts-expect-error TS(2339) FIXME: Property 'buildQueue' does not exist on type 'Netl... Remove this comment to see the full error message
await this.buildQueue

// @ts-expect-error TS(2339) FIXME: Property 'buildError' does not exist on type 'Netl... Remove this comment to see the full error message
if (this.buildError) {
// @ts-expect-error TS(2339) FIXME: Property 'buildError' does not exist on type 'Netl... Remove this comment to see the full error message
return { result: null, error: { errorMessage: this.buildError.message } }
}

// @ts-expect-error TS(2339) FIXME: Property 'isBackground' does not exist on type 'Ne... Remove this comment to see the full error message
const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous
const environment = {}

// @ts-expect-error TS(2339) FIXME: Property 'blobsContext' does not exist on type 'Ne... Remove this comment to see the full error message
if (this.blobsContext) {
const payload = JSON.stringify({
// @ts-expect-error TS(2339) FIXME: Property 'blobsContext' does not exist on type 'Ne... Remove this comment to see the full error message
url: this.blobsContext.edgeURL,
// @ts-expect-error TS(2339) FIXME: Property 'blobsContext' does not exist on type 'Ne... Remove this comment to see the full error message
token: this.blobsContext.token,
})

Expand All @@ -283,21 +262,16 @@ export default class NetlifyFunction {

/**
* Matches all routes agains the incoming request. If a match is found, then the matched route is returned.
* @param {string} rawPath
* @param {string} method
* @returns matched route
*/
// @ts-expect-error TS(7006) FIXME: Parameter 'rawPath' implicitly has an 'any' type.
async matchURLPath(rawPath, method) {
// @ts-expect-error TS(2339) FIXME: Property 'buildQueue' does not exist on type 'Netl... Remove this comment to see the full error message
async matchURLPath(rawPath: string, method: string, hasStaticFile: () => Promise<boolean>) {
await this.buildQueue

let path = rawPath !== '/' && rawPath.endsWith('/') ? rawPath.slice(0, -1) : rawPath
path = path.toLowerCase()
// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
const { routes = [] } = this.buildData
// @ts-expect-error TS(7031) FIXME: Binding element 'expression' implicitly has an 'an... Remove this comment to see the full error message
return routes.find(({ expression, literal, methods }) => {
const route = routes.find(({ expression, literal, methods }) => {
if (methods.length !== 0 && !methods.includes(method)) {
return false
}
Expand All @@ -314,10 +288,19 @@ export default class NetlifyFunction {

return false
})

if (!route) {
return
}

if (route.prefer_static && (await hasStaticFile())) {
return
}

return route
}

get runtimeAPIVersion() {
// @ts-expect-error TS(2339) FIXME: Property 'buildData' does not exist on type 'Netli... Remove this comment to see the full error message
return this.buildData?.runtimeAPIVersion ?? 1
}

Expand Down
4 changes: 2 additions & 2 deletions src/lib/functions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ export class FunctionsRegistry {
* function with the given name exists, returns an object with the function
* and the route set to `null`. Otherwise, `undefined` is returned,
*/
async getFunctionForURLPath(urlPath: string, method: string) {
async getFunctionForURLPath(urlPath: string, method: string, hasStaticFile: () => Promise<boolean>) {
// We're constructing a URL object just so that we can extract the path from
// the incoming URL. It doesn't really matter that we don't have the actual
// local URL with the correct port.
Expand Down Expand Up @@ -294,7 +294,7 @@ export class FunctionsRegistry {
}

for (const func of this.functions.values()) {
const route = await func.matchURLPath(url.pathname, method)
const route = await func.matchURLPath(url.pathname, method, hasStaticFile)

if (route) {
return { func, route }
Expand Down
11 changes: 8 additions & 3 deletions src/utils/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,10 @@ const serveRedirect = async function ({
return proxy.web(req, res, { target: options.functionsServer })
}

const matchingFunction = functionsRegistry && (await functionsRegistry.getFunctionForURLPath(destURL, req.method))
const destStaticFile = await getStatic(dest.pathname, options.publicFolder)
const matchingFunction =
functionsRegistry &&
(await functionsRegistry.getFunctionForURLPath(destURL, req.method, () => Boolean(destStaticFile)))
let statusValue
if (
match.force ||
Expand Down Expand Up @@ -697,8 +699,11 @@ const onRequest = async (
return proxy.web(req, res, { target: edgeFunctionsProxyURL })
}

const functionMatch = functionsRegistry && (await functionsRegistry.getFunctionForURLPath(req.url, req.method))

const functionMatch =
functionsRegistry &&
(await functionsRegistry.getFunctionForURLPath(req.url, req.method, () =>
getStatic(decodeURIComponent(reqToURL(req, req.url).pathname), settings.dist),
))
if (functionMatch) {
// Setting an internal header with the function name so that we don't
// have to match the URL again in the functions server.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export default async (req, context) => new Response(`With expression path: ${JSO

export const config = {
path: '/products/:sku',
preferStatic: true,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
this is a static page
7 changes: 7 additions & 0 deletions tests/integration/commands/dev/v2-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ describe.runIf(gte(version, '18.13.0'))('v2 api', () => {
expect(await response.text()).toBe(`With expression path: {"sku":"netlify"}`)
})

test<FixtureTestContext>('supports preferStatic', async ({ devServer }) => {
const url = `http://localhost:${devServer.port}/products/static`
const response = await fetch(url)
expect(response.status).toBe(200)
expect(await response.text()).toBe(`this is a static page\n`)
})

test<FixtureTestContext>('should serve the custom path ath the / route as specified in the in source config', async ({
devServer,
}) => {
Expand Down

2 comments on commit 6bd29e0

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,396
  • Package size: 404 MB
  • Number of ts-expect-error directives: 0

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Benchmark results

  • Dependency count: 1,396
  • Package size: 404 MB
  • Number of ts-expect-error directives: 0

Please sign in to comment.