-
Notifications
You must be signed in to change notification settings - Fork 393
/
Copy pathserver.ts
418 lines (362 loc) · 14.6 KB
/
server.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
import { Buffer } from 'buffer'
import { promises as fs } from 'fs'
import type { IncomingHttpHeaders } from 'http'
import path from 'path'
import express, { type Request, type RequestHandler } from 'express'
import expressLogging from 'express-logging'
import { jwtDecode } from 'jwt-decode'
import type BaseCommand from '../../commands/base-command.js'
import type { NetlifySite } from '../../commands/types.js'
import { NETLIFYDEVLOG, type NormalizedCachedConfigConfig, 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 type { BlobsContextWithEdgeAccess } from '../blobs/blobs.js'
import { headers as efHeaders } from '../edge-functions/headers.js'
import { getGeoLocation } from '../geo-location.js'
import type { CLIState, ServerSettings, SiteInfo } from '../../utils/types.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'
type FunctionsSettings = Pick<ServerSettings, 'functions' | 'functionsPort'>
const buildClientContext = function (headers: IncomingHttpHeaders) {
// 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: Request) =>
// 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: GetFunctionsServerOptions): RequestHandler {
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: Record<string, string[]> = {}
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: Record<string, string[]> = Object.entries(requestQuery).reduce(
(prev, [key, value]) => ({ ...prev, [key]: Array.isArray(value) ? value : [value] }),
{},
)
const geoLocation = await getGeoLocation({ ...options, mode: options.geolocationMode })
const multiValueHeaders: Record<string, string[]> = 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 })
}
}
}
interface GetFunctionsServerOptions {
functionsRegistry: FunctionsRegistry
siteUrl: string
siteInfo?: SiteInfo
accountId: string
geoCountry: string
offline: boolean
state: CLIState
config: NormalizedCachedConfigConfig
geolocationMode: 'cache' | 'update' | 'mock'
}
const getFunctionsServer = (options: GetFunctionsServerOptions) => {
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: {
blobsContext: BlobsContextWithEdgeAccess
command: BaseCommand
config: NormalizedCachedConfigConfig
capabilities: {
backgroundFunctions?: boolean
}
debug: boolean
loadDistFunctions?: boolean
// TODO(serhalp): This is confusing. Refactor to accept entire settings or rename or something?
settings: Pick<ServerSettings, 'functions' | 'functionsPort'>
site: NetlifySite
siteInfo: SiteInfo
timeouts: { backgroundFunctions: number; syncFunctions: number }
} & Omit<GetFunctionsServerOptions, 'functionsRegistry'>,
): Promise<FunctionsRegistry | undefined> => {
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: string[] = []
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: string[] = [
internalFunctionsDir,
command.netlify.frameworksAPIPaths.functions.path,
settings.functions,
].filter((x): x is string => 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,
}: {
debug: boolean
server: ReturnType<Awaited<typeof getFunctionsServer>>
settings: FunctionsSettings
}) => {
await new Promise<void>((resolve) => {
server.listen(settings.functionsPort, () => {
if (debug) {
log(`${NETLIFYDEVLOG} Functions server is listening on ${settings.functionsPort.toString()}`)
}
resolve()
})
})
}