-
Notifications
You must be signed in to change notification settings - Fork 734
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
1 parent
e9a1820
commit 9cef492
Showing
11 changed files
with
6,327 additions
and
2,122 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
32
packages/wrangler/pages/functions/filepath-routing.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
Oops, something went wrong.