@netlify/functions-dev 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.d.ts +43 -0
- package/dist/main.js +1112 -0
- package/dist/worker.js +106 -0
- package/package.json +65 -0
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DevEventHandler, Geolocation } from '@netlify/dev-utils';
|
|
2
|
+
import { Manifest } from '@netlify/zip-it-and-ship-it';
|
|
3
|
+
import { EnvironmentContext } from '@netlify/blobs';
|
|
4
|
+
|
|
5
|
+
interface FunctionRegistryOptions {
|
|
6
|
+
blobsContext?: EnvironmentContext;
|
|
7
|
+
destPath: string;
|
|
8
|
+
config: any;
|
|
9
|
+
debug?: boolean;
|
|
10
|
+
eventHandler?: DevEventHandler;
|
|
11
|
+
frameworksAPIFunctionsPath?: string;
|
|
12
|
+
internalFunctionsPath?: string;
|
|
13
|
+
manifest?: Manifest;
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
settings: any;
|
|
16
|
+
timeouts: any;
|
|
17
|
+
watch?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface FunctionMatch {
|
|
21
|
+
handle: (req: Request) => Promise<Response>;
|
|
22
|
+
preferStatic: boolean;
|
|
23
|
+
}
|
|
24
|
+
type FunctionsHandlerOptions = FunctionRegistryOptions & {
|
|
25
|
+
accountId?: string;
|
|
26
|
+
geolocation: Geolocation;
|
|
27
|
+
siteId?: string;
|
|
28
|
+
userFunctionsPath?: string;
|
|
29
|
+
};
|
|
30
|
+
declare class FunctionsHandler {
|
|
31
|
+
private accountID?;
|
|
32
|
+
private buildCache;
|
|
33
|
+
private geolocation;
|
|
34
|
+
private globalBuildDirectory;
|
|
35
|
+
private registry;
|
|
36
|
+
private scan;
|
|
37
|
+
private siteID?;
|
|
38
|
+
constructor({ accountId, geolocation, siteId, userFunctionsPath, ...registryOptions }: FunctionsHandlerOptions);
|
|
39
|
+
private invoke;
|
|
40
|
+
match(request: Request, buildDirectory?: string): Promise<FunctionMatch | undefined>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { type FunctionMatch, FunctionsHandler };
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
// src/main.ts
|
|
2
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
3
|
+
|
|
4
|
+
// src/registry.ts
|
|
5
|
+
import { stat } from "fs/promises";
|
|
6
|
+
import { createRequire as createRequire2 } from "module";
|
|
7
|
+
import { basename as basename2, extname as extname2, isAbsolute, join, resolve } from "path";
|
|
8
|
+
import { env } from "process";
|
|
9
|
+
import { watchDebounced } from "@netlify/dev-utils";
|
|
10
|
+
import { listFunctions } from "@netlify/zip-it-and-ship-it";
|
|
11
|
+
import extractZip from "extract-zip";
|
|
12
|
+
|
|
13
|
+
// src/function.ts
|
|
14
|
+
import { basename, extname } from "path";
|
|
15
|
+
import { version as nodeVersion } from "process";
|
|
16
|
+
import { headers as netlifyHeaders, renderFunctionErrorPage } from "@netlify/dev-utils";
|
|
17
|
+
import CronParser from "cron-parser";
|
|
18
|
+
import semver from "semver";
|
|
19
|
+
var BACKGROUND_FUNCTION_SUFFIX = "-background";
|
|
20
|
+
var TYPESCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".cts", ".mts", ".ts"]);
|
|
21
|
+
var V2_MIN_NODE_VERSION = "18.14.0";
|
|
22
|
+
var difference = (setA, setB) => new Set([...setA].filter((item) => !setB.has(item)));
|
|
23
|
+
var getNextRun = function(schedule) {
|
|
24
|
+
const cron = CronParser.parseExpression(schedule, {
|
|
25
|
+
tz: "Etc/UTC"
|
|
26
|
+
});
|
|
27
|
+
return cron.next().toDate();
|
|
28
|
+
};
|
|
29
|
+
var getBlobsEventProperty = (context) => ({
|
|
30
|
+
primary_region: context.primaryRegion,
|
|
31
|
+
url: context.edgeURL,
|
|
32
|
+
url_uncached: context.edgeURL,
|
|
33
|
+
token: context.token
|
|
34
|
+
});
|
|
35
|
+
var NetlifyFunction = class {
|
|
36
|
+
name;
|
|
37
|
+
mainFile;
|
|
38
|
+
displayName;
|
|
39
|
+
schedule;
|
|
40
|
+
runtime;
|
|
41
|
+
blobsContext;
|
|
42
|
+
config;
|
|
43
|
+
directory;
|
|
44
|
+
projectRoot;
|
|
45
|
+
settings;
|
|
46
|
+
timeoutBackground;
|
|
47
|
+
timeoutSynchronous;
|
|
48
|
+
// Determines whether this is a background function based on the function
|
|
49
|
+
// name.
|
|
50
|
+
isBackground;
|
|
51
|
+
buildQueue;
|
|
52
|
+
buildData;
|
|
53
|
+
buildError = null;
|
|
54
|
+
// List of the function's source files. This starts out as an empty set
|
|
55
|
+
// and will get populated on every build.
|
|
56
|
+
srcFiles = /* @__PURE__ */ new Set();
|
|
57
|
+
excludedRoutes;
|
|
58
|
+
routes;
|
|
59
|
+
constructor({
|
|
60
|
+
blobsContext,
|
|
61
|
+
config,
|
|
62
|
+
directory,
|
|
63
|
+
displayName,
|
|
64
|
+
excludedRoutes,
|
|
65
|
+
mainFile,
|
|
66
|
+
name,
|
|
67
|
+
projectRoot,
|
|
68
|
+
routes,
|
|
69
|
+
runtime,
|
|
70
|
+
settings,
|
|
71
|
+
timeoutBackground,
|
|
72
|
+
timeoutSynchronous
|
|
73
|
+
}) {
|
|
74
|
+
this.blobsContext = blobsContext;
|
|
75
|
+
this.config = config;
|
|
76
|
+
this.directory = directory;
|
|
77
|
+
this.excludedRoutes = excludedRoutes;
|
|
78
|
+
this.mainFile = mainFile;
|
|
79
|
+
this.name = name;
|
|
80
|
+
this.displayName = displayName ?? name;
|
|
81
|
+
this.projectRoot = projectRoot;
|
|
82
|
+
this.routes = routes;
|
|
83
|
+
this.runtime = runtime;
|
|
84
|
+
this.timeoutBackground = timeoutBackground;
|
|
85
|
+
this.timeoutSynchronous = timeoutSynchronous;
|
|
86
|
+
this.settings = settings;
|
|
87
|
+
this.isBackground = name.endsWith(BACKGROUND_FUNCTION_SUFFIX);
|
|
88
|
+
const functionConfig = config.functions?.[name];
|
|
89
|
+
this.schedule = functionConfig?.schedule;
|
|
90
|
+
this.srcFiles = /* @__PURE__ */ new Set();
|
|
91
|
+
}
|
|
92
|
+
get filename() {
|
|
93
|
+
if (!this.buildData?.mainFile) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return basename(this.buildData.mainFile);
|
|
97
|
+
}
|
|
98
|
+
getRecommendedExtension() {
|
|
99
|
+
if (this.buildData?.runtimeAPIVersion !== 2) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const extension = this.buildData?.mainFile ? extname(this.buildData.mainFile) : void 0;
|
|
103
|
+
const moduleFormat = this.buildData?.outputModuleFormat;
|
|
104
|
+
if (moduleFormat === "esm") {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (extension === ".ts") {
|
|
108
|
+
return ".mts";
|
|
109
|
+
}
|
|
110
|
+
if (extension === ".js") {
|
|
111
|
+
return ".mjs";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
hasValidName() {
|
|
115
|
+
return /^[A-Za-z0-9_-]+$/.test(this.name);
|
|
116
|
+
}
|
|
117
|
+
async isScheduled() {
|
|
118
|
+
await this.buildQueue;
|
|
119
|
+
return Boolean(this.schedule);
|
|
120
|
+
}
|
|
121
|
+
isSupported() {
|
|
122
|
+
return !(this.buildData?.runtimeAPIVersion === 2 && semver.lt(nodeVersion, V2_MIN_NODE_VERSION));
|
|
123
|
+
}
|
|
124
|
+
isTypeScript() {
|
|
125
|
+
if (this.filename === null) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return TYPESCRIPT_EXTENSIONS.has(extname(this.filename));
|
|
129
|
+
}
|
|
130
|
+
async getNextRun() {
|
|
131
|
+
if (!await this.isScheduled()) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return getNextRun(this.schedule);
|
|
135
|
+
}
|
|
136
|
+
// The `build` method transforms source files into invocable functions. Its
|
|
137
|
+
// return value is an object with:
|
|
138
|
+
//
|
|
139
|
+
// - `srcFilesDiff`: Files that were added and removed since the last time
|
|
140
|
+
// the function was built.
|
|
141
|
+
async build({ buildDirectory, cache }) {
|
|
142
|
+
this.buildQueue = this.runtime.getBuildFunction({
|
|
143
|
+
config: this.config,
|
|
144
|
+
directory: this.directory,
|
|
145
|
+
func: this,
|
|
146
|
+
projectRoot: this.projectRoot,
|
|
147
|
+
targetDirectory: buildDirectory
|
|
148
|
+
}).then((buildFunction2) => buildFunction2({ cache }));
|
|
149
|
+
try {
|
|
150
|
+
const buildData = await this.buildQueue;
|
|
151
|
+
if (buildData === void 0) {
|
|
152
|
+
throw new Error(`Could not build function ${this.name}`);
|
|
153
|
+
}
|
|
154
|
+
const { includedFiles = [], routes, schedule, srcFiles } = buildData;
|
|
155
|
+
const srcFilesSet = new Set(srcFiles);
|
|
156
|
+
const srcFilesDiff = this.getSrcFilesDiff(srcFilesSet);
|
|
157
|
+
this.buildData = buildData;
|
|
158
|
+
this.buildError = null;
|
|
159
|
+
this.routes = routes;
|
|
160
|
+
this.srcFiles = srcFilesSet;
|
|
161
|
+
this.schedule = schedule || this.schedule;
|
|
162
|
+
if (!this.isSupported()) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Function requires Node.js version ${V2_MIN_NODE_VERSION} or above, but ${nodeVersion.slice(
|
|
165
|
+
1
|
|
166
|
+
)} is installed. Refer to https://ntl.fyi/functions-runtime for information on how to update.`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return { includedFiles, srcFilesDiff };
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (error instanceof Error) {
|
|
172
|
+
this.buildError = error;
|
|
173
|
+
}
|
|
174
|
+
return { error };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
formatError(rawError, acceptsHTML) {
|
|
178
|
+
const error = this.normalizeError(rawError);
|
|
179
|
+
if (acceptsHTML) {
|
|
180
|
+
return JSON.stringify({
|
|
181
|
+
...error,
|
|
182
|
+
stackTrace: void 0,
|
|
183
|
+
trace: error.stackTrace
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return `${error.errorType}: ${error.errorMessage}
|
|
187
|
+
${error.stackTrace.join("\n")}`;
|
|
188
|
+
}
|
|
189
|
+
async getBuildData() {
|
|
190
|
+
await this.buildQueue;
|
|
191
|
+
return this.buildData;
|
|
192
|
+
}
|
|
193
|
+
// Compares a new set of source files against a previous one, returning an
|
|
194
|
+
// object with two Sets, one with added and the other with deleted files.
|
|
195
|
+
getSrcFilesDiff(newSrcFiles) {
|
|
196
|
+
const added = difference(newSrcFiles, this.srcFiles);
|
|
197
|
+
const deleted = difference(this.srcFiles, newSrcFiles);
|
|
198
|
+
return {
|
|
199
|
+
added,
|
|
200
|
+
deleted
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
async handleError(rawError, acceptsHTML) {
|
|
204
|
+
const errorString = typeof rawError === "string" ? rawError : this.formatError(rawError, acceptsHTML);
|
|
205
|
+
const status = 500;
|
|
206
|
+
if (acceptsHTML) {
|
|
207
|
+
const body = await renderFunctionErrorPage(errorString, "function");
|
|
208
|
+
return new Response(body, {
|
|
209
|
+
headers: {
|
|
210
|
+
"Content-Type": "text/html"
|
|
211
|
+
},
|
|
212
|
+
status
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return new Response(errorString, { status });
|
|
216
|
+
}
|
|
217
|
+
// Invokes the function and returns its response object.
|
|
218
|
+
async invoke({ buildCache = {}, buildDirectory, clientContext = {}, request, route }) {
|
|
219
|
+
if (buildDirectory) {
|
|
220
|
+
await this.build({ buildDirectory, cache: buildCache });
|
|
221
|
+
} else {
|
|
222
|
+
await this.buildQueue;
|
|
223
|
+
}
|
|
224
|
+
if (this.buildError) {
|
|
225
|
+
throw this.buildError;
|
|
226
|
+
}
|
|
227
|
+
const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous;
|
|
228
|
+
const environment = {};
|
|
229
|
+
if (this.blobsContext) {
|
|
230
|
+
const payload = JSON.stringify(getBlobsEventProperty(this.blobsContext));
|
|
231
|
+
request.headers.set(netlifyHeaders.BlobsInfo, Buffer.from(payload).toString("base64"));
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return await this.runtime.invokeFunction({
|
|
235
|
+
context: clientContext,
|
|
236
|
+
environment,
|
|
237
|
+
func: this,
|
|
238
|
+
request,
|
|
239
|
+
route,
|
|
240
|
+
timeout
|
|
241
|
+
});
|
|
242
|
+
} catch (error) {
|
|
243
|
+
const acceptsHTML = request.headers.get("accept")?.includes("text/html");
|
|
244
|
+
return await this.handleError(error, Boolean(acceptsHTML));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Matches all routes agains the incoming request. If a match is found, then the matched route is returned.
|
|
249
|
+
* @returns matched route
|
|
250
|
+
*/
|
|
251
|
+
async matchURLPath(rawPath, method) {
|
|
252
|
+
let path2 = rawPath !== "/" && rawPath.endsWith("/") ? rawPath.slice(0, -1) : rawPath;
|
|
253
|
+
path2 = path2.toLowerCase();
|
|
254
|
+
const { excludedRoutes = [], routes = [] } = this;
|
|
255
|
+
const matchingRoute = routes.find((route) => {
|
|
256
|
+
if (route.methods && route.methods.length !== 0 && !route.methods.includes(method)) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if ("literal" in route && route.literal !== void 0) {
|
|
260
|
+
return path2 === route.literal;
|
|
261
|
+
}
|
|
262
|
+
if ("expression" in route && route.expression !== void 0) {
|
|
263
|
+
const regex = new RegExp(route.expression);
|
|
264
|
+
return regex.test(path2);
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
});
|
|
268
|
+
if (!matchingRoute) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const isExcluded = excludedRoutes.some((excludedRoute) => {
|
|
272
|
+
if ("literal" in excludedRoute && excludedRoute.literal !== void 0) {
|
|
273
|
+
return path2 === excludedRoute.literal;
|
|
274
|
+
}
|
|
275
|
+
if ("expression" in excludedRoute && excludedRoute.expression !== void 0) {
|
|
276
|
+
const regex = new RegExp(excludedRoute.expression);
|
|
277
|
+
return regex.test(path2);
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
});
|
|
281
|
+
if (isExcluded) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
return matchingRoute;
|
|
285
|
+
}
|
|
286
|
+
normalizeError(error) {
|
|
287
|
+
if (error instanceof Error) {
|
|
288
|
+
const normalizedError = {
|
|
289
|
+
errorMessage: error.message,
|
|
290
|
+
errorType: error.name,
|
|
291
|
+
stackTrace: error.stack ? error.stack.split("\n") : []
|
|
292
|
+
};
|
|
293
|
+
if ("code" in error && error.code === "ERR_REQUIRE_ESM") {
|
|
294
|
+
return {
|
|
295
|
+
...normalizedError,
|
|
296
|
+
errorMessage: "a CommonJS file cannot import ES modules. Consider switching your function to ES modules. For more information, refer to https://ntl.fyi/functions-runtime."
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
return normalizedError;
|
|
300
|
+
}
|
|
301
|
+
const stackTrace = error.stackTrace.map((line) => ` at ${line}`);
|
|
302
|
+
return {
|
|
303
|
+
errorType: error.errorType,
|
|
304
|
+
errorMessage: error.errorMessage,
|
|
305
|
+
stackTrace
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
get runtimeAPIVersion() {
|
|
309
|
+
return this.buildData?.runtimeAPIVersion ?? 1;
|
|
310
|
+
}
|
|
311
|
+
setRoutes(routes) {
|
|
312
|
+
if (this.buildData) {
|
|
313
|
+
this.buildData.routes = routes;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
get url() {
|
|
317
|
+
const port = this.settings.port || this.settings.functionsPort;
|
|
318
|
+
const protocol = this.settings.https ? "https" : "http";
|
|
319
|
+
const url = new URL(`/.netlify/functions/${this.name}`, `${protocol}://localhost:${port}`);
|
|
320
|
+
return url.href;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// src/runtimes/nodejs/index.ts
|
|
325
|
+
import { createConnection } from "net";
|
|
326
|
+
import { pathToFileURL } from "url";
|
|
327
|
+
import { Worker } from "worker_threads";
|
|
328
|
+
import lambdaLocal from "lambda-local";
|
|
329
|
+
|
|
330
|
+
// src/runtimes/nodejs/builder.ts
|
|
331
|
+
import { writeFile } from "fs/promises";
|
|
332
|
+
import { createRequire } from "module";
|
|
333
|
+
import path from "path";
|
|
334
|
+
import { memoize } from "@netlify/dev-utils";
|
|
335
|
+
import { zipFunction, listFunction } from "@netlify/zip-it-and-ship-it";
|
|
336
|
+
import decache from "decache";
|
|
337
|
+
import { readPackageUp } from "read-package-up";
|
|
338
|
+
import sourceMapSupport from "source-map-support";
|
|
339
|
+
|
|
340
|
+
// src/runtimes/nodejs/config.ts
|
|
341
|
+
var normalizeFunctionsConfig = ({
|
|
342
|
+
functionsConfig = {},
|
|
343
|
+
projectRoot,
|
|
344
|
+
siteEnv = {}
|
|
345
|
+
}) => Object.entries(functionsConfig).reduce(
|
|
346
|
+
(result, [pattern, config]) => ({
|
|
347
|
+
...result,
|
|
348
|
+
[pattern]: {
|
|
349
|
+
externalNodeModules: config.external_node_modules,
|
|
350
|
+
includedFiles: config.included_files,
|
|
351
|
+
includedFilesBasePath: projectRoot,
|
|
352
|
+
ignoredNodeModules: config.ignored_node_modules,
|
|
353
|
+
nodeBundler: config.node_bundler === "esbuild" ? "esbuild_zisi" : config.node_bundler,
|
|
354
|
+
nodeVersion: siteEnv.AWS_LAMBDA_JS_RUNTIME,
|
|
355
|
+
processDynamicNodeImports: true,
|
|
356
|
+
schedule: config.schedule,
|
|
357
|
+
zipGo: true
|
|
358
|
+
}
|
|
359
|
+
}),
|
|
360
|
+
{}
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
// src/runtimes/nodejs/builder.ts
|
|
364
|
+
var require2 = createRequire(import.meta.url);
|
|
365
|
+
var addFunctionsConfigDefaults = (config) => ({
|
|
366
|
+
...config,
|
|
367
|
+
"*": {
|
|
368
|
+
nodeSourcemap: true,
|
|
369
|
+
...config["*"]
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
var buildFunction = async ({
|
|
373
|
+
cache,
|
|
374
|
+
config,
|
|
375
|
+
directory,
|
|
376
|
+
featureFlags,
|
|
377
|
+
func,
|
|
378
|
+
hasTypeModule,
|
|
379
|
+
projectRoot,
|
|
380
|
+
targetDirectory
|
|
381
|
+
}) => {
|
|
382
|
+
const zipOptions = {
|
|
383
|
+
archiveFormat: "none",
|
|
384
|
+
basePath: projectRoot,
|
|
385
|
+
config,
|
|
386
|
+
featureFlags: { ...featureFlags, zisi_functions_api_v2: true }
|
|
387
|
+
};
|
|
388
|
+
const functionDirectory = path.dirname(func.mainFile);
|
|
389
|
+
const entryPath = functionDirectory === directory ? func.mainFile : functionDirectory;
|
|
390
|
+
const buildResult = await memoize({
|
|
391
|
+
cache,
|
|
392
|
+
cacheKey: `zisi-${entryPath}`,
|
|
393
|
+
command: () => zipFunction(entryPath, targetDirectory, zipOptions)
|
|
394
|
+
});
|
|
395
|
+
if (!buildResult) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const {
|
|
399
|
+
entryFilename,
|
|
400
|
+
excludedRoutes,
|
|
401
|
+
includedFiles,
|
|
402
|
+
inputs,
|
|
403
|
+
mainFile,
|
|
404
|
+
outputModuleFormat,
|
|
405
|
+
path: functionPath,
|
|
406
|
+
routes,
|
|
407
|
+
runtimeAPIVersion,
|
|
408
|
+
schedule
|
|
409
|
+
} = buildResult;
|
|
410
|
+
const srcFiles = (inputs ?? []).filter((inputPath) => !inputPath.includes(`${path.sep}node_modules${path.sep}`));
|
|
411
|
+
const buildPath = path.join(functionPath, entryFilename);
|
|
412
|
+
if (hasTypeModule) {
|
|
413
|
+
await writeFile(
|
|
414
|
+
path.join(functionPath, `package.json`),
|
|
415
|
+
JSON.stringify({
|
|
416
|
+
type: "commonjs"
|
|
417
|
+
})
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
clearFunctionsCache(targetDirectory);
|
|
421
|
+
return {
|
|
422
|
+
buildPath,
|
|
423
|
+
excludedRoutes,
|
|
424
|
+
includedFiles,
|
|
425
|
+
outputModuleFormat,
|
|
426
|
+
mainFile,
|
|
427
|
+
routes,
|
|
428
|
+
runtimeAPIVersion,
|
|
429
|
+
srcFiles,
|
|
430
|
+
schedule,
|
|
431
|
+
targetDirectory
|
|
432
|
+
};
|
|
433
|
+
};
|
|
434
|
+
var parseFunctionForMetadata = async ({ config, mainFile, projectRoot }) => await listFunction(mainFile, {
|
|
435
|
+
config: netlifyConfigToZisiConfig(config.functions, projectRoot),
|
|
436
|
+
featureFlags: { zisi_functions_api_v2: true },
|
|
437
|
+
parseISC: true
|
|
438
|
+
});
|
|
439
|
+
var clearFunctionsCache = (functionsPath) => {
|
|
440
|
+
Object.keys(require2.cache).filter((key) => key.startsWith(functionsPath)).forEach(decache);
|
|
441
|
+
};
|
|
442
|
+
var netlifyConfigToZisiConfig = (functionsConfig, projectRoot) => addFunctionsConfigDefaults(normalizeFunctionsConfig({ functionsConfig, projectRoot }));
|
|
443
|
+
var getNoopBuilder = async ({ directory, func, metadata }) => {
|
|
444
|
+
const functionDirectory = path.dirname(func.mainFile);
|
|
445
|
+
const srcFiles = functionDirectory === directory ? [func.mainFile] : [functionDirectory];
|
|
446
|
+
const build = async () => ({
|
|
447
|
+
buildPath: "",
|
|
448
|
+
excludedRoutes: [],
|
|
449
|
+
includedFiles: [],
|
|
450
|
+
mainFile: func.mainFile,
|
|
451
|
+
outputModuleFormat: "cjs",
|
|
452
|
+
routes: [],
|
|
453
|
+
runtimeAPIVersion: func.runtimeAPIVersion,
|
|
454
|
+
schedule: metadata.schedule,
|
|
455
|
+
srcFiles
|
|
456
|
+
});
|
|
457
|
+
return {
|
|
458
|
+
build,
|
|
459
|
+
builderName: ""
|
|
460
|
+
};
|
|
461
|
+
};
|
|
462
|
+
var getZISIBuilder = async ({
|
|
463
|
+
config,
|
|
464
|
+
directory,
|
|
465
|
+
func,
|
|
466
|
+
metadata,
|
|
467
|
+
projectRoot,
|
|
468
|
+
targetDirectory
|
|
469
|
+
}) => {
|
|
470
|
+
const functionsConfig = netlifyConfigToZisiConfig(config.functions, projectRoot);
|
|
471
|
+
const packageJson = await readPackageUp({ cwd: path.dirname(func.mainFile) });
|
|
472
|
+
const hasTypeModule = Boolean(packageJson && packageJson.packageJson.type === "module");
|
|
473
|
+
const featureFlags = {};
|
|
474
|
+
if (metadata.runtimeAPIVersion === 2) {
|
|
475
|
+
featureFlags.zisi_pure_esm = true;
|
|
476
|
+
featureFlags.zisi_pure_esm_mjs = true;
|
|
477
|
+
} else {
|
|
478
|
+
const mustTranspile = [".mjs", ".ts", ".mts", ".cts"].includes(path.extname(func.mainFile));
|
|
479
|
+
const mustUseEsbuild = hasTypeModule || mustTranspile;
|
|
480
|
+
if (mustUseEsbuild && !functionsConfig["*"].nodeBundler) {
|
|
481
|
+
functionsConfig["*"].nodeBundler = "esbuild";
|
|
482
|
+
}
|
|
483
|
+
const { nodeBundler } = functionsConfig["*"];
|
|
484
|
+
const isUsingEsbuild = nodeBundler === "esbuild_zisi" || nodeBundler === "esbuild";
|
|
485
|
+
if (!isUsingEsbuild) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
sourceMapSupport.install();
|
|
490
|
+
return {
|
|
491
|
+
build: ({ cache = {} }) => buildFunction({
|
|
492
|
+
cache,
|
|
493
|
+
config: functionsConfig,
|
|
494
|
+
directory,
|
|
495
|
+
func,
|
|
496
|
+
projectRoot,
|
|
497
|
+
targetDirectory,
|
|
498
|
+
hasTypeModule,
|
|
499
|
+
featureFlags
|
|
500
|
+
}),
|
|
501
|
+
builderName: "zip-it-and-ship-it"
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
// src/runtimes/nodejs/lambda.ts
|
|
506
|
+
import { shouldBase64Encode } from "@netlify/dev-utils";
|
|
507
|
+
var headersObjectFromWebHeaders = (webHeaders) => {
|
|
508
|
+
const headers = {};
|
|
509
|
+
const multiValueHeaders = {};
|
|
510
|
+
webHeaders.forEach((value, key) => {
|
|
511
|
+
headers[key] = value;
|
|
512
|
+
multiValueHeaders[key] = value.split(",").map((value2) => value2.trim());
|
|
513
|
+
});
|
|
514
|
+
return {
|
|
515
|
+
headers,
|
|
516
|
+
multiValueHeaders
|
|
517
|
+
};
|
|
518
|
+
};
|
|
519
|
+
var webHeadersFromHeadersObject = (headersObject) => {
|
|
520
|
+
const headers = new Headers();
|
|
521
|
+
Object.entries(headersObject ?? {}).forEach(([name, value]) => {
|
|
522
|
+
if (value !== void 0) {
|
|
523
|
+
headers.set(name.toLowerCase(), value.toString());
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
return headers;
|
|
527
|
+
};
|
|
528
|
+
var lambdaEventFromWebRequest = async (request, route) => {
|
|
529
|
+
const url = new URL(request.url);
|
|
530
|
+
const queryStringParameters = {};
|
|
531
|
+
const multiValueQueryStringParameters = {};
|
|
532
|
+
url.searchParams.forEach((value, key) => {
|
|
533
|
+
queryStringParameters[key] = queryStringParameters[key] ? `${queryStringParameters[key]},${value}` : value;
|
|
534
|
+
multiValueQueryStringParameters[key] = [...multiValueQueryStringParameters[key] ?? [], value];
|
|
535
|
+
});
|
|
536
|
+
const { headers, multiValueHeaders } = headersObjectFromWebHeaders(request.headers);
|
|
537
|
+
const body = await request.text() || null;
|
|
538
|
+
return {
|
|
539
|
+
rawUrl: url.toString(),
|
|
540
|
+
rawQuery: url.search,
|
|
541
|
+
path: url.pathname,
|
|
542
|
+
httpMethod: request.method,
|
|
543
|
+
headers,
|
|
544
|
+
multiValueHeaders,
|
|
545
|
+
queryStringParameters,
|
|
546
|
+
multiValueQueryStringParameters,
|
|
547
|
+
body,
|
|
548
|
+
isBase64Encoded: shouldBase64Encode(request.headers.get("content-type") ?? ""),
|
|
549
|
+
route
|
|
550
|
+
};
|
|
551
|
+
};
|
|
552
|
+
var webResponseFromLambdaResponse = async (lambdaResponse) => {
|
|
553
|
+
return new Response(lambdaResponse.body, {
|
|
554
|
+
headers: webHeadersFromHeadersObject(lambdaResponse.headers),
|
|
555
|
+
status: lambdaResponse.statusCode
|
|
556
|
+
});
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// src/runtimes/nodejs/index.ts
|
|
560
|
+
var BLOBS_CONTEXT_VARIABLE = "NETLIFY_BLOBS_CONTEXT";
|
|
561
|
+
lambdaLocal.getLogger().level = "alert";
|
|
562
|
+
var nodeJSRuntime = {
|
|
563
|
+
getBuildFunction: async ({ config, directory, func, projectRoot, targetDirectory }) => {
|
|
564
|
+
const metadata = await parseFunctionForMetadata({ mainFile: func.mainFile, config, projectRoot });
|
|
565
|
+
const zisiBuilder = await getZISIBuilder({ config, directory, func, metadata, projectRoot, targetDirectory });
|
|
566
|
+
if (zisiBuilder) {
|
|
567
|
+
return zisiBuilder.build;
|
|
568
|
+
}
|
|
569
|
+
const noopBuilder = await getNoopBuilder({ config, directory, func, metadata, projectRoot, targetDirectory });
|
|
570
|
+
return noopBuilder.build;
|
|
571
|
+
},
|
|
572
|
+
invokeFunction: async ({ context, environment, func, request, route, timeout }) => {
|
|
573
|
+
const event = await lambdaEventFromWebRequest(request, route);
|
|
574
|
+
const buildData = await func.getBuildData();
|
|
575
|
+
if (buildData?.runtimeAPIVersion !== 2) {
|
|
576
|
+
const lambdaResponse2 = await invokeFunctionDirectly({ context, event, func, timeout });
|
|
577
|
+
return webResponseFromLambdaResponse(lambdaResponse2);
|
|
578
|
+
}
|
|
579
|
+
const workerData = {
|
|
580
|
+
clientContext: JSON.stringify(context),
|
|
581
|
+
environment,
|
|
582
|
+
event,
|
|
583
|
+
// If a function builder has defined a `buildPath` property, we use it.
|
|
584
|
+
// Otherwise, we'll invoke the function's main file.
|
|
585
|
+
// Because we use import() we have to use file:// URLs for Windows.
|
|
586
|
+
entryFilePath: pathToFileURL(buildData?.buildPath ?? func.mainFile).href,
|
|
587
|
+
timeoutMs: timeout * 1e3
|
|
588
|
+
};
|
|
589
|
+
const worker = new Worker(workerURL, { workerData });
|
|
590
|
+
const lambdaResponse = await new Promise((resolve2, reject) => {
|
|
591
|
+
worker.on("message", (result) => {
|
|
592
|
+
if (result?.streamPort) {
|
|
593
|
+
const client = createConnection(
|
|
594
|
+
{
|
|
595
|
+
port: result.streamPort,
|
|
596
|
+
host: "localhost"
|
|
597
|
+
},
|
|
598
|
+
() => {
|
|
599
|
+
result.body = client;
|
|
600
|
+
resolve2(result);
|
|
601
|
+
}
|
|
602
|
+
);
|
|
603
|
+
client.on("error", reject);
|
|
604
|
+
} else {
|
|
605
|
+
resolve2(result);
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
worker.on("error", reject);
|
|
609
|
+
});
|
|
610
|
+
return webResponseFromLambdaResponse(lambdaResponse);
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
var workerURL = new URL("worker.js", import.meta.url);
|
|
614
|
+
var invokeFunctionDirectly = async ({
|
|
615
|
+
context,
|
|
616
|
+
event,
|
|
617
|
+
func,
|
|
618
|
+
timeout
|
|
619
|
+
}) => {
|
|
620
|
+
const buildData = await func.getBuildData();
|
|
621
|
+
const lambdaPath = buildData?.buildPath ?? func.mainFile;
|
|
622
|
+
const result = await lambdaLocal.execute({
|
|
623
|
+
clientContext: JSON.stringify(context),
|
|
624
|
+
environment: {
|
|
625
|
+
// We've set the Blobs context on the parent process, which means it will
|
|
626
|
+
// be available to the Lambda. This would be inconsistent with production
|
|
627
|
+
// where only V2 functions get the context injected. To fix it, unset the
|
|
628
|
+
// context variable before invoking the function.
|
|
629
|
+
// This has the side-effect of also removing the variable from `process.env`.
|
|
630
|
+
[BLOBS_CONTEXT_VARIABLE]: void 0
|
|
631
|
+
},
|
|
632
|
+
event,
|
|
633
|
+
lambdaPath,
|
|
634
|
+
timeoutMs: timeout * 1e3,
|
|
635
|
+
verboseLevel: 3,
|
|
636
|
+
esm: lambdaPath.endsWith(".mjs")
|
|
637
|
+
});
|
|
638
|
+
return result;
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// src/runtimes/index.ts
|
|
642
|
+
var runtimes = {
|
|
643
|
+
js: nodeJSRuntime
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// src/registry.ts
|
|
647
|
+
var DEFAULT_FUNCTION_URL_EXPRESSION = /^\/.netlify\/(functions|builders)\/([^/]+).*/;
|
|
648
|
+
var TYPES_PACKAGE = "@netlify/functions";
|
|
649
|
+
var FunctionsRegistry = class {
|
|
650
|
+
/**
|
|
651
|
+
* Context object for Netlify Blobs
|
|
652
|
+
*/
|
|
653
|
+
blobsContext;
|
|
654
|
+
/**
|
|
655
|
+
* The functions held by the registry
|
|
656
|
+
*/
|
|
657
|
+
functions = /* @__PURE__ */ new Map();
|
|
658
|
+
/**
|
|
659
|
+
* File watchers for function files. Maps function names to objects built
|
|
660
|
+
* by the `watchDebounced` utility.
|
|
661
|
+
*/
|
|
662
|
+
functionWatchers = /* @__PURE__ */ new Map();
|
|
663
|
+
/**
|
|
664
|
+
* Keeps track of whether we've checked whether `TYPES_PACKAGE` is
|
|
665
|
+
* installed.
|
|
666
|
+
*/
|
|
667
|
+
hasCheckedTypesPackage = false;
|
|
668
|
+
buildCache;
|
|
669
|
+
config;
|
|
670
|
+
debug;
|
|
671
|
+
destPath;
|
|
672
|
+
directoryWatchers;
|
|
673
|
+
handleEvent;
|
|
674
|
+
frameworksAPIFunctionsPath;
|
|
675
|
+
internalFunctionsPath;
|
|
676
|
+
manifest;
|
|
677
|
+
projectRoot;
|
|
678
|
+
timeouts;
|
|
679
|
+
settings;
|
|
680
|
+
watch;
|
|
681
|
+
constructor({
|
|
682
|
+
blobsContext,
|
|
683
|
+
config,
|
|
684
|
+
debug = false,
|
|
685
|
+
destPath,
|
|
686
|
+
eventHandler,
|
|
687
|
+
frameworksAPIFunctionsPath,
|
|
688
|
+
internalFunctionsPath,
|
|
689
|
+
manifest,
|
|
690
|
+
projectRoot,
|
|
691
|
+
settings,
|
|
692
|
+
timeouts,
|
|
693
|
+
watch
|
|
694
|
+
}) {
|
|
695
|
+
this.blobsContext = blobsContext;
|
|
696
|
+
this.config = config;
|
|
697
|
+
this.debug = debug;
|
|
698
|
+
this.destPath = destPath;
|
|
699
|
+
this.frameworksAPIFunctionsPath = frameworksAPIFunctionsPath;
|
|
700
|
+
this.handleEvent = eventHandler ?? (() => {
|
|
701
|
+
});
|
|
702
|
+
this.internalFunctionsPath = internalFunctionsPath;
|
|
703
|
+
this.projectRoot = projectRoot;
|
|
704
|
+
this.timeouts = timeouts;
|
|
705
|
+
this.settings = settings;
|
|
706
|
+
this.watch = watch === true;
|
|
707
|
+
this.buildCache = {};
|
|
708
|
+
this.directoryWatchers = /* @__PURE__ */ new Map();
|
|
709
|
+
this.manifest = manifest;
|
|
710
|
+
}
|
|
711
|
+
async checkTypesPackage() {
|
|
712
|
+
if (this.hasCheckedTypesPackage) {
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
this.hasCheckedTypesPackage = true;
|
|
716
|
+
const require3 = createRequire2(this.projectRoot);
|
|
717
|
+
try {
|
|
718
|
+
require3.resolve(TYPES_PACKAGE, { paths: [this.projectRoot] });
|
|
719
|
+
} catch (error) {
|
|
720
|
+
if (error?.code === "MODULE_NOT_FOUND") {
|
|
721
|
+
this.handleEvent({ name: "FunctionMissingTypesPackageEvent" });
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Builds a function and sets up the appropriate file watchers so that any
|
|
727
|
+
* changes will trigger another build.
|
|
728
|
+
*/
|
|
729
|
+
async buildFunctionAndWatchFiles(func, firstLoad = false) {
|
|
730
|
+
if (!firstLoad) {
|
|
731
|
+
this.handleEvent({ function: func, name: "FunctionReloadingEvent" });
|
|
732
|
+
}
|
|
733
|
+
const {
|
|
734
|
+
error: buildError,
|
|
735
|
+
includedFiles,
|
|
736
|
+
srcFilesDiff
|
|
737
|
+
} = await func.build({ buildDirectory: this.destPath, cache: this.buildCache });
|
|
738
|
+
if (buildError) {
|
|
739
|
+
this.handleEvent({ function: func, name: "FunctionBuildErrorEvent" });
|
|
740
|
+
} else {
|
|
741
|
+
this.handleEvent({ firstLoad, function: func, name: "FunctionLoadedEvent" });
|
|
742
|
+
}
|
|
743
|
+
if (func.isTypeScript()) {
|
|
744
|
+
this.checkTypesPackage();
|
|
745
|
+
}
|
|
746
|
+
if (!srcFilesDiff) {
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (!this.watch) {
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const watcher = this.functionWatchers.get(func.name);
|
|
753
|
+
if (watcher) {
|
|
754
|
+
srcFilesDiff.deleted.forEach((path2) => {
|
|
755
|
+
watcher.unwatch(path2);
|
|
756
|
+
});
|
|
757
|
+
srcFilesDiff.added.forEach((path2) => {
|
|
758
|
+
watcher.add(path2);
|
|
759
|
+
});
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
if (srcFilesDiff.added.size !== 0) {
|
|
763
|
+
const filesToWatch = [...srcFilesDiff.added, ...includedFiles];
|
|
764
|
+
const newWatcher = await watchDebounced(filesToWatch, {
|
|
765
|
+
onChange: () => {
|
|
766
|
+
this.buildFunctionAndWatchFiles(func, false);
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
this.functionWatchers.set(func.name, newWatcher);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
set eventHandler(handler) {
|
|
773
|
+
this.handleEvent = handler;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Returns a function by name.
|
|
777
|
+
*/
|
|
778
|
+
get(name) {
|
|
779
|
+
return this.functions.get(name);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Looks for the first function that matches a given URL path. If a match is
|
|
783
|
+
* found, returns an object with the function and the route. If the URL path
|
|
784
|
+
* matches the default functions URL (i.e. can only be for a function) but no
|
|
785
|
+
* function with the given name exists, returns an object with the function
|
|
786
|
+
* and the route set to `null`. Otherwise, `undefined` is returned,
|
|
787
|
+
*/
|
|
788
|
+
async getFunctionForURLPath(urlPath, method) {
|
|
789
|
+
const url = new URL(`http://localhost${urlPath}`);
|
|
790
|
+
const defaultURLMatch = DEFAULT_FUNCTION_URL_EXPRESSION.exec(url.pathname);
|
|
791
|
+
if (defaultURLMatch) {
|
|
792
|
+
const func = this.get(defaultURLMatch[2]);
|
|
793
|
+
if (!func) {
|
|
794
|
+
return { func: null, route: null };
|
|
795
|
+
}
|
|
796
|
+
const { routes = [] } = func;
|
|
797
|
+
if (routes.length !== 0) {
|
|
798
|
+
this.handleEvent({
|
|
799
|
+
function: func,
|
|
800
|
+
name: "FunctionNotInvokableOnPathEvent",
|
|
801
|
+
urlPath
|
|
802
|
+
});
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
return { func, route: null };
|
|
806
|
+
}
|
|
807
|
+
for (const func of this.functions.values()) {
|
|
808
|
+
const route = await func.matchURLPath(url.pathname, method);
|
|
809
|
+
if (route) {
|
|
810
|
+
return { func, route };
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
isInternalFunction(func) {
|
|
815
|
+
if (this.internalFunctionsPath && func.mainFile.includes(this.internalFunctionsPath)) {
|
|
816
|
+
return true;
|
|
817
|
+
}
|
|
818
|
+
if (this.frameworksAPIFunctionsPath && func.mainFile.includes(this.frameworksAPIFunctionsPath)) {
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Adds a function to the registry
|
|
825
|
+
*/
|
|
826
|
+
async registerFunction(name, func, isReload = false) {
|
|
827
|
+
this.handleEvent({ function: func, name: "FunctionRegisteredEvent" });
|
|
828
|
+
if (extname2(func.mainFile) === ".zip") {
|
|
829
|
+
const unzippedDirectory = await this.unzipFunction(func);
|
|
830
|
+
const manifestEntry = (this.manifest?.functions || []).find((manifestFunc) => manifestFunc.name === func.name);
|
|
831
|
+
if (!manifestEntry) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (this.debug) {
|
|
835
|
+
this.handleEvent({ function: func, name: "FunctionExtractedEvent" });
|
|
836
|
+
}
|
|
837
|
+
func.setRoutes(manifestEntry?.routes);
|
|
838
|
+
try {
|
|
839
|
+
const v2EntryPointPath = join(unzippedDirectory, "___netlify-entry-point.mjs");
|
|
840
|
+
await stat(v2EntryPointPath);
|
|
841
|
+
func.mainFile = v2EntryPointPath;
|
|
842
|
+
} catch {
|
|
843
|
+
func.mainFile = join(unzippedDirectory, basename2(manifestEntry.mainFile));
|
|
844
|
+
}
|
|
845
|
+
} else if (this.watch) {
|
|
846
|
+
this.buildFunctionAndWatchFiles(func, !isReload);
|
|
847
|
+
}
|
|
848
|
+
this.functions.set(name, func);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* A proxy to zip-it-and-ship-it's `listFunctions` method. It exists just so
|
|
852
|
+
* that we can mock it in tests.
|
|
853
|
+
*/
|
|
854
|
+
async listFunctions(...args) {
|
|
855
|
+
return await listFunctions(...args);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Takes a list of directories and scans for functions. It keeps tracks of
|
|
859
|
+
* any functions in those directories that we've previously seen, and takes
|
|
860
|
+
* care of registering and unregistering functions as they come and go.
|
|
861
|
+
*/
|
|
862
|
+
async scan(relativeDirs) {
|
|
863
|
+
const directories = relativeDirs.filter((dir) => Boolean(dir)).map((dir) => isAbsolute(dir) ? dir : join(this.projectRoot, dir));
|
|
864
|
+
if (directories.length === 0) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const functions = await this.listFunctions(directories, {
|
|
868
|
+
featureFlags: {
|
|
869
|
+
buildRustSource: env.NETLIFY_EXPERIMENTAL_BUILD_RUST_SOURCE === "true"
|
|
870
|
+
},
|
|
871
|
+
configFileDirectories: [this.internalFunctionsPath].filter(Boolean),
|
|
872
|
+
config: this.config.functions,
|
|
873
|
+
parseISC: true
|
|
874
|
+
});
|
|
875
|
+
const ignoredFunctions = new Set(
|
|
876
|
+
functions.filter(
|
|
877
|
+
(func) => this.isInternalFunction(func) && this.functions.has(func.name) && !this.isInternalFunction(this.functions.get(func.name))
|
|
878
|
+
).map((func) => func.name)
|
|
879
|
+
);
|
|
880
|
+
const deletedFunctions = [...this.functions.values()].filter((oldFunc) => {
|
|
881
|
+
const isFound = functions.some(
|
|
882
|
+
(newFunc) => ignoredFunctions.has(newFunc.name) || newFunc.name === oldFunc.name && newFunc.mainFile === oldFunc.mainFile
|
|
883
|
+
);
|
|
884
|
+
return !isFound;
|
|
885
|
+
});
|
|
886
|
+
await Promise.all(deletedFunctions.map((func) => this.unregisterFunction(func)));
|
|
887
|
+
const deletedFunctionNames = new Set(deletedFunctions.map((func) => func.name));
|
|
888
|
+
const addedFunctions = await Promise.all(
|
|
889
|
+
// zip-it-and-ship-it returns an array sorted based on which extension should have precedence,
|
|
890
|
+
// where the last ones precede the previous ones. This is why
|
|
891
|
+
// we reverse the array so we get the right functions precedence in the CLI.
|
|
892
|
+
functions.reverse().map(async ({ displayName, excludedRoutes, mainFile, name, routes, runtime: runtimeName }) => {
|
|
893
|
+
if (ignoredFunctions.has(name)) {
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
const runtime = runtimes[runtimeName];
|
|
897
|
+
if (runtime === void 0) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
if (this.functions.has(name)) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const directory = directories.find((directory2) => mainFile.startsWith(directory2));
|
|
904
|
+
if (directory === void 0) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
const func = new NetlifyFunction({
|
|
908
|
+
blobsContext: this.blobsContext,
|
|
909
|
+
config: this.config,
|
|
910
|
+
directory,
|
|
911
|
+
displayName,
|
|
912
|
+
excludedRoutes,
|
|
913
|
+
mainFile,
|
|
914
|
+
name,
|
|
915
|
+
projectRoot: this.projectRoot,
|
|
916
|
+
routes,
|
|
917
|
+
runtime,
|
|
918
|
+
settings: this.settings,
|
|
919
|
+
timeoutBackground: this.timeouts.backgroundFunctions,
|
|
920
|
+
timeoutSynchronous: this.timeouts.syncFunctions
|
|
921
|
+
});
|
|
922
|
+
const isReload = deletedFunctionNames.has(name);
|
|
923
|
+
await this.registerFunction(name, func, isReload);
|
|
924
|
+
return func;
|
|
925
|
+
})
|
|
926
|
+
);
|
|
927
|
+
const addedFunctionNames = new Set(addedFunctions.filter(Boolean).map((func) => func?.name));
|
|
928
|
+
deletedFunctions.forEach(async (func) => {
|
|
929
|
+
if (addedFunctionNames.has(func.name)) {
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.handleEvent({ function: func, name: "FunctionRemovedEvent" });
|
|
933
|
+
});
|
|
934
|
+
if (this.watch) {
|
|
935
|
+
await Promise.all(directories.map((path2) => this.setupDirectoryWatcher(path2)));
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Creates a watcher that looks at files being added or removed from a
|
|
940
|
+
* functions directory. It doesn't care about files being changed, because
|
|
941
|
+
* those will be handled by each functions' watcher.
|
|
942
|
+
*/
|
|
943
|
+
async setupDirectoryWatcher(directory) {
|
|
944
|
+
if (this.directoryWatchers.has(directory)) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
const watcher = await watchDebounced(directory, {
|
|
948
|
+
depth: 1,
|
|
949
|
+
onAdd: () => {
|
|
950
|
+
this.scan([directory]);
|
|
951
|
+
},
|
|
952
|
+
onUnlink: () => {
|
|
953
|
+
this.scan([directory]);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
this.directoryWatchers.set(directory, watcher);
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Removes a function from the registry and closes its file watchers.
|
|
960
|
+
*/
|
|
961
|
+
async unregisterFunction(func) {
|
|
962
|
+
const { name } = func;
|
|
963
|
+
this.functions.delete(name);
|
|
964
|
+
const watcher = this.functionWatchers.get(name);
|
|
965
|
+
if (watcher) {
|
|
966
|
+
await watcher.close();
|
|
967
|
+
}
|
|
968
|
+
this.functionWatchers.delete(name);
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Takes a zipped function and extracts its contents to an internal directory.
|
|
972
|
+
*/
|
|
973
|
+
async unzipFunction(func) {
|
|
974
|
+
const targetDirectory = resolve(this.projectRoot, this.destPath, ".unzipped", func.name);
|
|
975
|
+
await extractZip(func.mainFile, { dir: targetDirectory });
|
|
976
|
+
return targetDirectory;
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
// src/server/client-context.ts
|
|
981
|
+
import { jwtDecode } from "jwt-decode";
|
|
982
|
+
var buildClientContext = (headers) => {
|
|
983
|
+
if (!headers.authorization) return;
|
|
984
|
+
const parts = headers.authorization.split(" ");
|
|
985
|
+
if (parts.length !== 2 || parts[0] !== "Bearer") return;
|
|
986
|
+
const identity = {
|
|
987
|
+
url: "https://netlify-dev-locally-emulated-identity.netlify.app/.netlify/identity",
|
|
988
|
+
// {
|
|
989
|
+
// "source": "netlify dev",
|
|
990
|
+
// "testData": "NETLIFY_DEV_LOCALLY_EMULATED_IDENTITY"
|
|
991
|
+
// }
|
|
992
|
+
token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzb3VyY2UiOiJuZXRsaWZ5IGRldiIsInRlc3REYXRhIjoiTkVUTElGWV9ERVZfTE9DQUxMWV9FTVVMQVRFRF9JREVOVElUWSJ9.2eSDqUOZAOBsx39FHFePjYj12k0LrxldvGnlvDu3GMI"
|
|
993
|
+
};
|
|
994
|
+
try {
|
|
995
|
+
const user = jwtDecode(parts[1]);
|
|
996
|
+
const netlifyContext = JSON.stringify({
|
|
997
|
+
identity,
|
|
998
|
+
user
|
|
999
|
+
});
|
|
1000
|
+
return {
|
|
1001
|
+
identity,
|
|
1002
|
+
user,
|
|
1003
|
+
custom: {
|
|
1004
|
+
netlify: Buffer.from(netlifyContext).toString("base64")
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
} catch {
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
// src/main.ts
|
|
1012
|
+
var CLOCKWORK_USERAGENT = "Netlify Clockwork";
|
|
1013
|
+
var UNLINKED_SITE_MOCK_ID = "unlinked";
|
|
1014
|
+
var FunctionsHandler = class {
|
|
1015
|
+
accountID;
|
|
1016
|
+
buildCache;
|
|
1017
|
+
geolocation;
|
|
1018
|
+
globalBuildDirectory;
|
|
1019
|
+
registry;
|
|
1020
|
+
scan;
|
|
1021
|
+
siteID;
|
|
1022
|
+
constructor({ accountId, geolocation, siteId, userFunctionsPath, ...registryOptions }) {
|
|
1023
|
+
const registry = new FunctionsRegistry(registryOptions);
|
|
1024
|
+
this.accountID = accountId;
|
|
1025
|
+
this.buildCache = {};
|
|
1026
|
+
this.geolocation = geolocation;
|
|
1027
|
+
this.globalBuildDirectory = registryOptions.destPath;
|
|
1028
|
+
this.registry = registry;
|
|
1029
|
+
this.scan = registry.scan([userFunctionsPath]);
|
|
1030
|
+
this.siteID = siteId;
|
|
1031
|
+
}
|
|
1032
|
+
async invoke(request, route, func, buildDirectory) {
|
|
1033
|
+
let remoteAddress = request.headers.get("x-forwarded-for") || "";
|
|
1034
|
+
remoteAddress = remoteAddress.split(remoteAddress.includes(".") ? ":" : ",").pop()?.trim() ?? "";
|
|
1035
|
+
request.headers.set("x-nf-client-connection-ip", remoteAddress);
|
|
1036
|
+
if (this.accountID) {
|
|
1037
|
+
request.headers.set("x-nf-account-id", this.accountID);
|
|
1038
|
+
}
|
|
1039
|
+
request.headers.set("x-nf-site-id", this.siteID ?? UNLINKED_SITE_MOCK_ID);
|
|
1040
|
+
request.headers.set("x-nf-geo", Buffer2.from(JSON.stringify(this.geolocation)).toString("base64"));
|
|
1041
|
+
const { headers: headersObject } = headersObjectFromWebHeaders(request.headers);
|
|
1042
|
+
const clientContext = buildClientContext(headersObject) || {};
|
|
1043
|
+
if (func.isBackground) {
|
|
1044
|
+
await func.invoke({
|
|
1045
|
+
buildCache: this.buildCache,
|
|
1046
|
+
buildDirectory: buildDirectory ?? this.globalBuildDirectory,
|
|
1047
|
+
request,
|
|
1048
|
+
route
|
|
1049
|
+
});
|
|
1050
|
+
return new Response(null, { status: 202 });
|
|
1051
|
+
}
|
|
1052
|
+
if (await func.isScheduled()) {
|
|
1053
|
+
const newRequest = new Request(request, {
|
|
1054
|
+
...request,
|
|
1055
|
+
method: "POST"
|
|
1056
|
+
});
|
|
1057
|
+
newRequest.headers.set("user-agent", CLOCKWORK_USERAGENT);
|
|
1058
|
+
newRequest.headers.set("x-nf-event", "schedule");
|
|
1059
|
+
return await func.invoke({
|
|
1060
|
+
buildCache: this.buildCache,
|
|
1061
|
+
buildDirectory: buildDirectory ?? this.globalBuildDirectory,
|
|
1062
|
+
clientContext,
|
|
1063
|
+
request: newRequest,
|
|
1064
|
+
route
|
|
1065
|
+
});
|
|
1066
|
+
}
|
|
1067
|
+
return await func.invoke({
|
|
1068
|
+
buildCache: this.buildCache,
|
|
1069
|
+
buildDirectory: buildDirectory ?? this.globalBuildDirectory,
|
|
1070
|
+
clientContext,
|
|
1071
|
+
request,
|
|
1072
|
+
route
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
async match(request, buildDirectory) {
|
|
1076
|
+
await this.scan;
|
|
1077
|
+
const url = new URL(request.url);
|
|
1078
|
+
const match = await this.registry.getFunctionForURLPath(url.pathname, request.method);
|
|
1079
|
+
if (!match) {
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const functionName = match?.func?.name;
|
|
1083
|
+
if (!functionName) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
const matchingRoute = match.route?.pattern;
|
|
1087
|
+
const func = this.registry.get(functionName);
|
|
1088
|
+
if (func === void 0) {
|
|
1089
|
+
return {
|
|
1090
|
+
handle: async () => new Response("Function not found...", {
|
|
1091
|
+
status: 404
|
|
1092
|
+
}),
|
|
1093
|
+
preferStatic: false
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
if (!func.hasValidName()) {
|
|
1097
|
+
return {
|
|
1098
|
+
handle: async () => new Response("Function name should consist only of alphanumeric characters, hyphen & underscores.", {
|
|
1099
|
+
status: 400
|
|
1100
|
+
}),
|
|
1101
|
+
preferStatic: false
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
return {
|
|
1105
|
+
handle: (request2) => this.invoke(request2, matchingRoute, func, buildDirectory),
|
|
1106
|
+
preferStatic: match.route?.prefer_static ?? false
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
export {
|
|
1111
|
+
FunctionsHandler
|
|
1112
|
+
};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// This is a JavaScript file because we need to locate it at runtime using the
|
|
3
|
+
// `Worker` API and using a `.ts` complicates things. To make it type-safe,
|
|
4
|
+
// we use JSDoc annotations.
|
|
5
|
+
import { createServer } from 'node:net'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
import { isMainThread, workerData, parentPort } from 'node:worker_threads'
|
|
8
|
+
|
|
9
|
+
import { isStream } from 'is-stream'
|
|
10
|
+
import lambdaLocal from 'lambda-local'
|
|
11
|
+
import sourceMapSupport from 'source-map-support'
|
|
12
|
+
|
|
13
|
+
// https://github.com/nodejs/undici/blob/a36e299d544863c5ade17d4090181be894366024/lib/web/fetch/constants.js#L6
|
|
14
|
+
const nullBodyStatus = new Set([101, 204, 205, 304])
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef HandlerResponse
|
|
18
|
+
* @type {import('../../../src/function/handler_response.js').HandlerResponse}
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef WorkerResult
|
|
23
|
+
* @type {HandlerResponse & { streamPort?: number }}
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
if (isMainThread) {
|
|
27
|
+
throw new Error(`Do not import "${import.meta.url}" in the main thread.`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
sourceMapSupport.install()
|
|
31
|
+
|
|
32
|
+
lambdaLocal.getLogger().level = 'alert'
|
|
33
|
+
|
|
34
|
+
const { clientContext, entryFilePath, environment = {}, event, timeoutMs } = workerData
|
|
35
|
+
|
|
36
|
+
// Injecting into the environment any properties passed in by the parent.
|
|
37
|
+
for (const key in environment) {
|
|
38
|
+
process.env[key] = environment[key]
|
|
39
|
+
}
|
|
40
|
+
const lambdaFunc = await import(entryFilePath)
|
|
41
|
+
const invocationResult = /** @type {HandlerResponse} */ (
|
|
42
|
+
await lambdaLocal.execute({
|
|
43
|
+
clientContext,
|
|
44
|
+
event,
|
|
45
|
+
lambdaFunc,
|
|
46
|
+
region: 'dev',
|
|
47
|
+
timeoutMs,
|
|
48
|
+
verboseLevel: 3,
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* When the result body is a stream and result status code allow to have a body,
|
|
54
|
+
* open up a http server that proxies back to the main thread and resolve with server port.
|
|
55
|
+
* Otherwise, resolve with undefined.
|
|
56
|
+
*
|
|
57
|
+
* @param {HandlerResponse} invocationResult
|
|
58
|
+
* @returns {Promise<number | undefined>}
|
|
59
|
+
*/
|
|
60
|
+
async function getStreamPortForStreamingResponse(invocationResult) {
|
|
61
|
+
// if we don't have result or result's body is not a stream, we do not need a stream port
|
|
62
|
+
if (!invocationResult || !isStream(invocationResult.body)) {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const { body } = invocationResult
|
|
67
|
+
|
|
68
|
+
delete invocationResult.body
|
|
69
|
+
|
|
70
|
+
// For streaming responses, lambda-local always returns a result with body stream.
|
|
71
|
+
// We need to discard it if result's status code does not allow response to have a body.
|
|
72
|
+
const shouldNotHaveABody = nullBodyStatus.has(invocationResult.statusCode)
|
|
73
|
+
if (shouldNotHaveABody) {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// create a server that will proxy the body stream back to the main thread
|
|
78
|
+
return await new Promise((resolve, reject) => {
|
|
79
|
+
const server = createServer((socket) => {
|
|
80
|
+
body.pipe(socket).on('end', () => server.close())
|
|
81
|
+
})
|
|
82
|
+
server.on('error', (error) => {
|
|
83
|
+
reject(error)
|
|
84
|
+
})
|
|
85
|
+
server.listen({ port: 0, host: 'localhost' }, () => {
|
|
86
|
+
const address = server.address()
|
|
87
|
+
|
|
88
|
+
/** @type {number | undefined} */
|
|
89
|
+
let streamPort
|
|
90
|
+
if (address && typeof address !== 'string') {
|
|
91
|
+
streamPort = address.port
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
resolve(streamPort)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const streamPort = await getStreamPortForStreamingResponse(invocationResult)
|
|
100
|
+
|
|
101
|
+
if (parentPort) {
|
|
102
|
+
/** @type {WorkerResult} */
|
|
103
|
+
const message = { ...invocationResult, streamPort }
|
|
104
|
+
|
|
105
|
+
parentPort.postMessage(message)
|
|
106
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@netlify/functions-dev",
|
|
3
|
+
"main": "./dist/main.js",
|
|
4
|
+
"types": "./dist/main.d.ts",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": {
|
|
9
|
+
"types": "./dist/main.d.ts",
|
|
10
|
+
"default": "./dist/main.js"
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"description": "Local dev emulation of Netlify Functions",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/**/*.js",
|
|
18
|
+
"dist/**/*.mjs",
|
|
19
|
+
"dist/**/*.d.ts",
|
|
20
|
+
"dist/**/*.d.mts"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "tsup-node --watch",
|
|
24
|
+
"build": "tsup-node",
|
|
25
|
+
"prepack": "npm run build",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:dev": "vitest"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"netlify",
|
|
31
|
+
"functions",
|
|
32
|
+
"serverless"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": "netlify/primitives",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/netlify/primitives/issues"
|
|
38
|
+
},
|
|
39
|
+
"author": "Netlify Inc.",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@netlify/blobs": "10.1.0",
|
|
42
|
+
"@netlify/dev-utils": "4.3.0",
|
|
43
|
+
"@netlify/functions": "5.0.0",
|
|
44
|
+
"@netlify/zip-it-and-ship-it": "^14.1.3",
|
|
45
|
+
"cron-parser": "^4.9.0",
|
|
46
|
+
"decache": "^4.6.2",
|
|
47
|
+
"extract-zip": "^2.0.1",
|
|
48
|
+
"is-stream": "^4.0.1",
|
|
49
|
+
"jwt-decode": "^4.0.0",
|
|
50
|
+
"lambda-local": "^2.2.0",
|
|
51
|
+
"read-package-up": "^11.0.0",
|
|
52
|
+
"semver": "^7.6.3",
|
|
53
|
+
"source-map-support": "^0.5.21"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/semver": "^7.5.8",
|
|
57
|
+
"@types/source-map-support": "^0.5.10",
|
|
58
|
+
"npm-run-all2": "^5.0.0",
|
|
59
|
+
"tsup": "^8.0.2",
|
|
60
|
+
"vitest": "^3.0.0"
|
|
61
|
+
},
|
|
62
|
+
"engines": {
|
|
63
|
+
"node": ">=20.6.1"
|
|
64
|
+
}
|
|
65
|
+
}
|