Skip to content

Commit 0ca267a

Browse files
authored
Feat: Generic Code Patching (#781)
* poc for code patching * review fix * fix build * use ContentUpdater plugin * added ast-grep code patcher * added some manifests * fix and add logs * fix to apply patch one after the other * review fix * improve typing * simplified CodePatcher types * fix linting * changed versions to be more user friendly * review * review fix
1 parent cf807d4 commit 0ca267a

File tree

11 files changed

+675
-8
lines changed

11 files changed

+675
-8
lines changed

packages/open-next/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"README.md"
3838
],
3939
"dependencies": {
40+
"@ast-grep/napi": "^0.35.0",
4041
"@aws-sdk/client-cloudfront": "3.398.0",
4142
"@aws-sdk/client-dynamodb": "^3.398.0",
4243
"@aws-sdk/client-lambda": "^3.398.0",
@@ -50,7 +51,8 @@
5051
"esbuild": "0.19.2",
5152
"express": "5.0.1",
5253
"path-to-regexp": "^6.3.0",
53-
"urlpattern-polyfill": "^10.0.0"
54+
"urlpattern-polyfill": "^10.0.0",
55+
"yaml": "^2.7.0"
5456
},
5557
"devDependencies": {
5658
"@types/aws-lambda": "^8.10.109",

packages/open-next/src/adapters/config/util.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,14 @@ export function loadBuildId(nextDir: string) {
2121
return fs.readFileSync(filePath, "utf-8").trim();
2222
}
2323

24-
export function loadHtmlPages(nextDir: string) {
24+
export function loadPagesManifest(nextDir: string) {
2525
const filePath = path.join(nextDir, "server/pages-manifest.json");
2626
const json = fs.readFileSync(filePath, "utf-8");
27-
return Object.entries(JSON.parse(json))
27+
return JSON.parse(json);
28+
}
29+
30+
export function loadHtmlPages(nextDir: string) {
31+
return Object.entries(loadPagesManifest(nextDir))
2832
.filter(([_, value]) => (value as string).endsWith(".html"))
2933
.map(([key]) => key);
3034
}

packages/open-next/src/build/copyTracedFiles.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@ import {
1313
} from "node:fs";
1414
import path from "node:path";
1515

16-
import { loadConfig, loadPrerenderManifest } from "config/util.js";
16+
import {
17+
loadAppPathsManifest,
18+
loadBuildId,
19+
loadConfig,
20+
loadFunctionsConfigManifest,
21+
loadMiddlewareManifest,
22+
loadPagesManifest,
23+
loadPrerenderManifest,
24+
} from "config/util.js";
1725
import { getCrossPlatformPathRegex } from "utils/regex.js";
1826
import logger from "../logger.js";
1927
import { MIDDLEWARE_TRACE_FILE } from "./constant.js";
@@ -50,6 +58,18 @@ interface CopyTracedFilesOptions {
5058
skipServerFiles?: boolean;
5159
}
5260

61+
export function getManifests(nextDir: string) {
62+
return {
63+
buildId: loadBuildId(nextDir),
64+
config: loadConfig(nextDir),
65+
prerenderManifest: loadPrerenderManifest(nextDir),
66+
pagesManifest: loadPagesManifest(nextDir),
67+
appPathsManifest: loadAppPathsManifest(nextDir),
68+
middlewareManifest: loadMiddlewareManifest(nextDir),
69+
functionsConfigManifest: loadFunctionsConfigManifest(nextDir),
70+
};
71+
}
72+
5373
// eslint-disable-next-line sonarjs/cognitive-complexity
5474
export async function copyTracedFiles({
5575
buildOutputPath,
@@ -323,4 +343,9 @@ File ${fullFilePath} does not exist
323343
}
324344

325345
logger.debug("copyTracedFiles:", Date.now() - tsStart, "ms");
346+
347+
return {
348+
tracedFiles: Array.from(filesToCopy.values()),
349+
manifests: getManifests(standaloneNextDir),
350+
};
326351
}

packages/open-next/src/build/createServerBundle.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import path from "node:path";
33

44
import type { FunctionOptions, SplittedFunctionOptions } from "types/open-next";
55

6+
import type { Plugin } from "esbuild";
67
import logger from "../logger.js";
78
import { minifyAll } from "../minimize-js.js";
9+
import { ContentUpdater } from "../plugins/content-updater.js";
810
import { openNextReplacementPlugin } from "../plugins/replacement.js";
911
import { openNextResolvePlugin } from "../plugins/resolve.js";
1012
import { getCrossPlatformPathRegex } from "../utils/regex.js";
@@ -14,8 +16,20 @@ import { copyTracedFiles } from "./copyTracedFiles.js";
1416
import { generateEdgeBundle } from "./edge/createEdgeBundle.js";
1517
import * as buildHelper from "./helper.js";
1618
import { installDependencies } from "./installDeps.js";
19+
import { type CodePatcher, applyCodePatches } from "./patch/codePatcher.js";
20+
21+
interface CodeCustomization {
22+
// These patches are meant to apply on user and next generated code
23+
additionalCodePatches?: CodePatcher[];
24+
// These plugins are meant to apply during the esbuild bundling process.
25+
// This will only apply to OpenNext code.
26+
additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[];
27+
}
1728

18-
export async function createServerBundle(options: buildHelper.BuildOptions) {
29+
export async function createServerBundle(
30+
options: buildHelper.BuildOptions,
31+
codeCustomization?: CodeCustomization,
32+
) {
1933
const { config } = options;
2034
const foundRoutes = new Set<string>();
2135
// Get all functions to build
@@ -36,7 +50,7 @@ export async function createServerBundle(options: buildHelper.BuildOptions) {
3650
if (fnOptions.runtime === "edge") {
3751
await generateEdgeBundle(name, options, fnOptions);
3852
} else {
39-
await generateBundle(name, options, fnOptions);
53+
await generateBundle(name, options, fnOptions, codeCustomization);
4054
}
4155
});
4256

@@ -101,6 +115,7 @@ async function generateBundle(
101115
name: string,
102116
options: buildHelper.BuildOptions,
103117
fnOptions: SplittedFunctionOptions,
118+
codeCustomization?: CodeCustomization,
104119
) {
105120
const { appPath, appBuildOutputPath, config, outputDir, monorepoRoot } =
106121
options;
@@ -153,14 +168,20 @@ async function generateBundle(
153168
buildHelper.copyEnvFile(appBuildOutputPath, packagePath, outputPath);
154169

155170
// Copy all necessary traced files
156-
await copyTracedFiles({
171+
const { tracedFiles, manifests } = await copyTracedFiles({
157172
buildOutputPath: appBuildOutputPath,
158173
packagePath,
159174
outputDir: outputPath,
160175
routes: fnOptions.routes ?? ["app/page.tsx"],
161176
bundledNextServer: isBundled,
162177
});
163178

179+
const additionalCodePatches = codeCustomization?.additionalCodePatches ?? [];
180+
181+
await applyCodePatches(options, tracedFiles, manifests, [
182+
...additionalCodePatches,
183+
]);
184+
164185
// Build Lambda code
165186
// note: bundle in OpenNext package b/c the adapter relies on the
166187
// "serverless-http" package which is not a dependency in user's
@@ -179,6 +200,12 @@ async function generateBundle(
179200

180201
const disableRouting = isBefore13413 || config.middleware?.external;
181202

203+
const updater = new ContentUpdater(options);
204+
205+
const additionalPlugins = codeCustomization?.additionalPlugins
206+
? codeCustomization.additionalPlugins(updater)
207+
: [];
208+
182209
const plugins = [
183210
openNextReplacementPlugin({
184211
name: `requestHandlerOverride ${name}`,
@@ -204,6 +231,9 @@ async function generateBundle(
204231
fnName: name,
205232
overrides,
206233
}),
234+
...additionalPlugins,
235+
// The content updater plugin must be the last plugin
236+
updater.plugin,
207237
];
208238

209239
const outfileExt = fnOptions.runtime === "deno" ? "ts" : "mjs";

packages/open-next/src/build/edge/createEdgeBundle.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs";
22

33
import fs from "node:fs";
44
import path from "node:path";
5-
import { build } from "esbuild";
5+
import { type Plugin, build } from "esbuild";
66
import type { MiddlewareInfo } from "types/next-types";
77
import type {
88
IncludedConverter,
@@ -16,6 +16,7 @@ import type {
1616
import { loadMiddlewareManifest } from "config/util.js";
1717
import type { OriginResolver } from "types/overrides.js";
1818
import logger from "../../logger.js";
19+
import { ContentUpdater } from "../../plugins/content-updater.js";
1920
import { openNextEdgePlugins } from "../../plugins/edge.js";
2021
import { openNextExternalMiddlewarePlugin } from "../../plugins/externalMiddleware.js";
2122
import { openNextReplacementPlugin } from "../../plugins/replacement.js";
@@ -39,6 +40,7 @@ interface BuildEdgeBundleOptions {
3940
additionalExternals?: string[];
4041
onlyBuildOnce?: boolean;
4142
name: string;
43+
additionalPlugins?: (contentUpdater: ContentUpdater) => Plugin[];
4244
}
4345

4446
export async function buildEdgeBundle({
@@ -53,13 +55,18 @@ export async function buildEdgeBundle({
5355
additionalExternals,
5456
onlyBuildOnce,
5557
name,
58+
additionalPlugins: additionalPluginsFn,
5659
}: BuildEdgeBundleOptions) {
5760
const isInCloudfare = await isEdgeRuntime(overrides);
5861
function override<T extends keyof Override>(target: T) {
5962
return typeof overrides?.[target] === "string"
6063
? overrides[target]
6164
: undefined;
6265
}
66+
const contentUpdater = new ContentUpdater(options);
67+
const additionalPlugins = additionalPluginsFn
68+
? additionalPluginsFn(contentUpdater)
69+
: [];
6370
await esbuildAsync(
6471
{
6572
entryPoints: [entrypoint],
@@ -98,6 +105,9 @@ export async function buildEdgeBundle({
98105
nextDir: path.join(options.appBuildOutputPath, ".next"),
99106
isInCloudfare,
100107
}),
108+
...additionalPlugins,
109+
// The content updater plugin must be the last plugin
110+
contentUpdater.plugin,
101111
],
102112
treeShaking: true,
103113
alias: {
@@ -173,6 +183,7 @@ export async function generateEdgeBundle(
173183
name: string,
174184
options: BuildOptions,
175185
fnOptions: SplittedFunctionOptions,
186+
additionalPlugins: (contentUpdater: ContentUpdater) => Plugin[] = () => [],
176187
) {
177188
logger.info(`Generating edge bundle for: ${name}`);
178189

@@ -226,5 +237,6 @@ export async function generateEdgeBundle(
226237
overrides: fnOptions.override,
227238
additionalExternals: options.config.edgeExternals,
228239
name,
240+
additionalPlugins,
229241
});
230242
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Mostly copied from the cloudflare adapter
2+
import { readFileSync } from "node:fs";
3+
4+
import {
5+
type Edit,
6+
Lang,
7+
type NapiConfig,
8+
type SgNode,
9+
parse,
10+
} from "@ast-grep/napi";
11+
import yaml from "yaml";
12+
import type { PatchCodeFn } from "./codePatcher";
13+
14+
/**
15+
* fix has the same meaning as in yaml rules
16+
* see https://ast-grep.github.io/guide/rewrite-code.html#using-fix-in-yaml-rule
17+
*/
18+
export type RuleConfig = NapiConfig & { fix?: string };
19+
20+
/**
21+
* Returns the `Edit`s and `Match`es for an ast-grep rule in yaml format
22+
*
23+
* The rule must have a `fix` to rewrite the matched node.
24+
*
25+
* Tip: use https://ast-grep.github.io/playground.html to create rules.
26+
*
27+
* @param rule The rule. Either a yaml string or an instance of `RuleConfig`
28+
* @param root The root node
29+
* @param once only apply once
30+
* @returns A list of edits and a list of matches.
31+
*/
32+
export function applyRule(
33+
rule: string | RuleConfig,
34+
root: SgNode,
35+
{ once = false } = {},
36+
) {
37+
const ruleConfig: RuleConfig =
38+
typeof rule === "string" ? yaml.parse(rule) : rule;
39+
if (ruleConfig.transform) {
40+
throw new Error("transform is not supported");
41+
}
42+
if (!ruleConfig.fix) {
43+
throw new Error("no fix to apply");
44+
}
45+
46+
const fix = ruleConfig.fix;
47+
48+
const matches = once
49+
? [root.find(ruleConfig)].filter((m) => m !== null)
50+
: root.findAll(ruleConfig);
51+
52+
const edits: Edit[] = [];
53+
54+
matches.forEach((match) => {
55+
edits.push(
56+
match.replace(
57+
// Replace known placeholders by their value
58+
fix
59+
.replace(/\$\$\$([A-Z0-9_]+)/g, (_m, name) =>
60+
match
61+
.getMultipleMatches(name)
62+
.map((n) => n.text())
63+
.join(""),
64+
)
65+
.replace(
66+
/\$([A-Z0-9_]+)/g,
67+
(m, name) => match.getMatch(name)?.text() ?? m,
68+
),
69+
),
70+
);
71+
});
72+
73+
return { edits, matches };
74+
}
75+
76+
/**
77+
* Parse a file and obtain its root.
78+
*
79+
* @param path The file path
80+
* @param lang The language to parse. Defaults to TypeScript.
81+
* @returns The root for the file.
82+
*/
83+
export function parseFile(path: string, lang = Lang.TypeScript) {
84+
return parse(lang, readFileSync(path, { encoding: "utf-8" })).root();
85+
}
86+
87+
/**
88+
* Patches the code from by applying the rule.
89+
*
90+
* This function is mainly for on off edits and tests,
91+
* use `getRuleEdits` to apply multiple rules.
92+
*
93+
* @param code The source code
94+
* @param rule The astgrep rule (yaml or NapiConfig)
95+
* @param lang The language used by the source code
96+
* @param lang Whether to apply the rule only once
97+
* @returns The patched code
98+
*/
99+
export function patchCode(
100+
code: string,
101+
rule: string | RuleConfig,
102+
{ lang = Lang.TypeScript, once = false } = {},
103+
): string {
104+
const node = parse(lang, code).root();
105+
const { edits } = applyRule(rule, node, { once });
106+
return node.commitEdits(edits);
107+
}
108+
109+
export function createPatchCode(
110+
rule: string | RuleConfig,
111+
lang = Lang.TypeScript,
112+
): PatchCodeFn {
113+
return async ({ code }) => patchCode(code, rule, { lang });
114+
}

0 commit comments

Comments
 (0)