Skip to content

Commit

Permalink
Pages Functions Compiler (#160)
Browse files Browse the repository at this point in the history
* Copy across pages-functions-compiler

* Extract out build command

* Add watch mode

* Abstract running processes quitter

* Add pages-functions-compiler watcher

* Remove @cloudflare/pages-functions-compiler dep

* Woohoo! Most of functions

* Regen package-lock.json

* Add lib folder

* Add acorn dep

* Move path-to-regexp to actual deps

* Wait for initial build

* esbuild doesn't actually report when complete. Switching to sleep

* Type the process spawner

* Add sourcemaps

* Remove unused mock-fs

* Add changeset

* Fix lint errors

* Format with prettier

* Move estree types to wrangler

* Add missing deps, rename lib folder and refactor top-level functions to as functions

* Regen package-lock.json

* Extract back out pages functions pieces"

* Use __dirname over import.meta.url shenanigans

* Wait for esbuild to notify build completion rather than naively sleeping

* Exit with a non-zero code when erroring

* Return void rather than undefined from exit function

* Exit more aggressively to ensure all children are terminated esp. esbuild

* Fix no-shadow eslint errors
  • Loading branch information
GregBrimble authored Jan 5, 2022
1 parent e9a1820 commit 9cef492
Show file tree
Hide file tree
Showing 11 changed files with 6,327 additions and 2,122 deletions.
7 changes: 7 additions & 0 deletions .changeset/lucky-goats-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"wrangler": patch
---

Adds the logic of @cloudflare/pages-functions-compiler directly into wrangler. This generates a Worker from a folder of functions.

Also adds support for sourcemaps and automatically watching dependents to trigger a re-build.
7,523 changes: 5,484 additions & 2,039 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,26 @@
"cli"
],
"dependencies": {
"@cloudflare/pages-functions-compiler": "0.3.8",
"esbuild": "0.14.1",
"miniflare": "2.0.0-rc.5",
"path-to-regexp": "^6.2.0",
"semiver": "^1.1.0"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
},
"devDependencies": {
"@babel/types": "^7.16.0",
"@iarna/toml": "^2.2.5",
"@types/mime": "^2.0.3",
"@types/estree": "^0.0.50",
"@types/react": "^17.0.37",
"@types/serve-static": "^1.13.10",
"@types/signal-exit": "^3.0.1",
"@types/ws": "^8.2.1",
"@types/yargs": "^17.0.7",
"acorn": "^8.6.0",
"acorn-walk": "^8.2.0",
"chokidar": "^3.5.2",
"clipboardy": "^3.0.0",
"command-exists": "^1.2.9",
Expand All @@ -67,7 +71,6 @@
"mime": "^3.0.0",
"node-fetch": "^3.1.0",
"open": "^8.4.0",
"path-to-regexp": "^6.2.0",
"react": "^17.0.2",
"react-error-boundary": "^3.1.4",
"serve-static": "^1.14.1",
Expand All @@ -80,6 +83,7 @@
"files": [
"src",
"bin",
"pages",
"miniflare-config-stubs",
"wrangler-dist",
"static-asset-facade.js",
Expand Down
57 changes: 57 additions & 0 deletions packages/wrangler/pages/functions/buildWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import path from "path";
import { build } from "esbuild";

type Options = {
routesModule: string;
outfile: string;
minify?: boolean;
sourcemap?: boolean;
watch?: boolean;
onEnd?: () => void;
};

export function buildWorker({
routesModule,
outfile = "bundle.js",
minify = false,
sourcemap = false,
watch = false,
onEnd = () => {},
}: Options) {
return build({
entryPoints: [
path.resolve(__dirname, "../pages/functions/template-worker.ts"),
],
inject: [routesModule],
bundle: true,
format: "esm",
target: "esnext",
outfile,
minify,
sourcemap,
watch,
allowOverwrite: true,
plugins: [
{
name: "wrangler notifier and monitor",
setup(pluginBuild) {
pluginBuild.onEnd((result) => {
if (result.errors.length > 0) {
console.error(
`${result.errors.length} error(s) and ${result.warnings.length} warning(s) when compiling Worker.`
);
} else if (result.warnings.length > 0) {
console.warn(
`${result.warnings.length} warning(s) when compiling Worker.`
);
onEnd();
} else {
console.log("Compiled Worker successfully.");
onEnd();
}
});
},
},
],
});
}
32 changes: 32 additions & 0 deletions packages/wrangler/pages/functions/filepath-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { compareRoutes } from "./filepath-routing";

describe("compareRoutes()", () => {
test("routes with fewer segments come after those with more segments", () => {
expect(compareRoutes("/foo", "/foo/bar")).toBe(1);
});

test("routes with wildcard segments come after those without", () => {
expect(compareRoutes("/:foo*", "/foo")).toBe(1);
expect(compareRoutes("/:foo*", "/:foo")).toBe(1);
});

test("routes with dynamic segments come after those without", () => {
expect(compareRoutes("/:foo", "/foo")).toBe(1);
});

test("routes with dynamic segments occuring earlier come after those with dynamic segments in later positions", () => {
expect(compareRoutes("/foo/:id/bar", "/foo/bar/:id")).toBe(1);
});

test("routes with no HTTP method come after those specifying a method", () => {
expect(compareRoutes("/foo", "GET /foo")).toBe(1);
});

test("two equal routes are sorted according to their original position in the list", () => {
expect(compareRoutes("GET /foo", "GET /foo")).toBe(0);
});

test("it returns -1 if the first argument should appear first in the list", () => {
expect(compareRoutes("GET /foo", "/foo")).toBe(-1);
});
});
223 changes: 223 additions & 0 deletions packages/wrangler/pages/functions/filepath-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import path from "path";
import fs from "fs/promises";
import { transform } from "esbuild";
import * as acorn from "acorn";
import * as acornWalk from "acorn-walk";
import type { Config } from "./routes";
import type { Identifier } from "estree";
import type { ExportNamedDeclaration } from "@babel/types";

type Arguments = {
baseDir: string;
baseURL: string;
};

export async function generateConfigFromFileTree({
baseDir,
baseURL,
}: Arguments) {
let routeEntries: [
string,
{ [key in "module" | "middleware"]?: string[] }
][] = [] as any;

if (!baseURL.startsWith("/")) {
baseURL = `/${baseURL}`;
}

if (baseURL.endsWith("/")) {
baseURL = baseURL.slice(0, -1);
}

await forEachFile(baseDir, async (filepath) => {
const ext = path.extname(filepath);
if (/\.(mjs|js|ts)/.test(ext)) {
// transform the code to ensure we're working with vanilla JS + ESM
const { code } = await transform(await fs.readFile(filepath, "utf-8"), {
loader: ext === ".ts" ? "ts" : "js",
});

// parse each file into an AST and search for module exports that match "onRequest" and friends
const ast = acorn.parse(code, {
ecmaVersion: "latest",
sourceType: "module",
});
acornWalk.simple(ast, {
ExportNamedDeclaration(_node) {
const node: ExportNamedDeclaration = _node as any;

// this is an array because multiple things can be exported from a single statement
// i.e. `export {foo, bar}` or `export const foo = "f", bar = "b"`
const exportNames: string[] = [];

if (node.declaration) {
const declaration = node.declaration;

// `export async function onRequest() {...}`
if (declaration.type === "FunctionDeclaration") {
exportNames.push(declaration.id.name);
}

// `export const onRequestGet = () => {}, onRequestPost = () => {}`
if (declaration.type === "VariableDeclaration") {
exportNames.push(
...declaration.declarations.map(
(variableDeclarator) =>
(variableDeclarator.id as unknown as Identifier).name
)
);
}
}

// `export {foo, bar}`
if (node.specifiers.length) {
exportNames.push(
...node.specifiers.map(
(exportSpecifier) =>
(exportSpecifier.exported as unknown as Identifier).name
)
);
}

for (const exportName of exportNames) {
const [match, method] =
exportName.match(
/^onRequest(Get|Post|Put|Patch|Delete|Options|Head)?$/
) ?? [];

if (match) {
const basename = path.basename(filepath).slice(0, -ext.length);

const isIndexFile = basename === "index";
// TODO: deprecate _middleware_ in favor of _middleware
const isMiddlewareFile =
basename === "_middleware" || basename === "_middleware_";

let routePath = path
.relative(baseDir, filepath)
.slice(0, -ext.length);

if (isIndexFile || isMiddlewareFile) {
routePath = path.dirname(routePath);
}

if (routePath === ".") {
routePath = "";
}

routePath = `${baseURL}/${routePath}`;

routePath = routePath.replace(/\[\[(.+)]]/g, ":$1*"); // transform [[id]] => :id*
routePath = routePath.replace(/\[(.+)]/g, ":$1"); // transform [id] => :id

if (method) {
routePath = `${method.toUpperCase()} ${routePath}`;
}

routeEntries.push([
routePath,
{
[isMiddlewareFile ? "middleware" : "module"]: [
`${path.relative(baseDir, filepath)}:${exportName}`,
],
},
]);
}
}
},
});
}
});

// Combine together any routes (index routes) which contain both a module and a middleware
routeEntries = routeEntries.reduce(
(acc: typeof routeEntries, [routePath, routeHandler]) => {
const existingRouteEntry = acc.find(
(routeEntry) => routeEntry[0] === routePath
);
if (existingRouteEntry !== undefined) {
existingRouteEntry[1] = {
...existingRouteEntry[1],
...routeHandler,
};
} else {
acc.push([routePath, routeHandler]);
}
return acc;
},
[]
);

routeEntries.sort(([pathA], [pathB]) => compareRoutes(pathA, pathB));

return { routes: Object.fromEntries(routeEntries) } as Config;
}

// Ensure routes are produced in order of precedence so that
// more specific routes aren't occluded from matching due to
// less specific routes appearing first in the route list.
export function compareRoutes(a: string, b: string) {
function parseRoutePath(routePath: string) {
let [method, segmentedPath] = routePath.split(" ");
if (!segmentedPath) {
segmentedPath = method;
method = null;
}

const segments = segmentedPath.slice(1).split("/");
return [method, segments];
}

const [methodA, segmentsA] = parseRoutePath(a);
const [methodB, segmentsB] = parseRoutePath(b);

// sort routes with fewer segments after those with more segments
if (segmentsA.length !== segmentsB.length) {
return segmentsB.length - segmentsA.length;
}

for (let i = 0; i < segmentsA.length; i++) {
const isWildcardA = segmentsA[i].includes("*");
const isWildcardB = segmentsB[i].includes("*");
const isParamA = segmentsA[i].includes(":");
const isParamB = segmentsB[i].includes(":");

// sort wildcard segments after non-wildcard segments
if (isWildcardA && !isWildcardB) return 1;
if (!isWildcardA && isWildcardB) return -1;

// sort dynamic param segments after non-param segments
if (isParamA && !isParamB) return 1;
if (!isParamA && isParamB) return -1;
}

// sort routes that specify an HTTP before those that don't
if (methodA && !methodB) return -1;
if (!methodA && methodB) return 1;

// all else equal, just sort them lexicographically
return a.localeCompare(b);
}

async function forEachFile<T>(
baseDir: string,
fn: (filepath: string) => T | Promise<T>
) {
const searchPaths = [baseDir];
const returnValues: T[] = [];

while (searchPaths.length) {
const cwd = searchPaths.shift();
const dir = await fs.readdir(cwd, { withFileTypes: true });
for (const entry of dir) {
const pathname = path.join(cwd, entry.name);
if (entry.isDirectory()) {
searchPaths.push(pathname);
} else if (entry.isFile()) {
returnValues.push(await fn(pathname));
}
}
}

return returnValues;
}
Loading

0 comments on commit 9cef492

Please sign in to comment.