Skip to content

Commit 4cfa89b

Browse files
implement webpack chunks file updating using ast manipulation
this allows us not to have to require the use of the `serverMinification: false` option (as we're no longer relying on known variable names)
1 parent b84cd5f commit 4cfa89b

16 files changed

+1577
-52
lines changed

.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
.wrangler
33
pnpm-lock.yaml
44
.vscode/setting.json
5+
test-fixtures
6+
test-snapshots

builder/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
"version": "0.0.1",
55
"scripts": {
66
"build": "tsup",
7-
"build:watch": "tsup --watch src"
7+
"build:watch": "tsup --watch src",
8+
"test": "vitest --run",
9+
"test:watch": "vitest"
810
},
911
"bin": "dist/index.mjs",
1012
"files": [
@@ -32,6 +34,8 @@
3234
"glob": "^11.0.0",
3335
"next": "14.2.5",
3436
"tsup": "^8.2.4",
35-
"typescript": "^5.5.4"
37+
"typescript": "^5.5.4",
38+
"vitest": "^2.1.1",
39+
"ts-morph": "^23.0.0"
3640
}
3741
}

builder/src/build/build-worker.ts

Lines changed: 2 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { NextjsAppPaths } from "../nextjs-paths";
22
import { build, Plugin } from "esbuild";
3-
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import { readFileSync } from "node:fs";
44
import { cp, readFile, writeFile } from "node:fs/promises";
55

66
import { patchRequire } from "./patches/investigated/patch-require";
@@ -12,6 +12,7 @@ import { patchFindDir } from "./patches/to-investigate/patch-find-dir";
1212
import { inlineNextRequire } from "./patches/to-investigate/inline-next-require";
1313
import { inlineEvalManifest } from "./patches/to-investigate/inline-eval-manifest";
1414
import { patchWranglerDeps } from "./patches/to-investigate/wrangler-deps";
15+
import { updateWebpackChunksFile } from "./patches/investigated/update-webpack-chunks-file";
1516

1617
/**
1718
* Using the Next.js build output in the `.next` directory builds a workerd compatible output
@@ -151,49 +152,6 @@ async function updateWorkerBundledCode(
151152
await writeFile(workerOutputFile, patchedCode);
152153
}
153154

154-
/**
155-
* Fixes the webpack-runtime.js file by removing its webpack dynamic requires.
156-
*
157-
* This hack is especially bad for two reasons:
158-
* - it requires setting `experimental.serverMinification` to `false` in the app's config file
159-
* - indicates that files inside the output directory still get a hold of files from the outside: `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`
160-
* so this shows that not everything that's needed to deploy the application is in the output directory...
161-
*/
162-
async function updateWebpackChunksFile(nextjsAppPaths: NextjsAppPaths) {
163-
console.log("# updateWebpackChunksFile");
164-
const webpackRuntimeFile = `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`;
165-
166-
console.log({ webpackRuntimeFile });
167-
168-
const fileContent = readFileSync(webpackRuntimeFile, "utf-8");
169-
170-
const chunks = readdirSync(`${nextjsAppPaths.standaloneAppServerDir}/chunks`)
171-
.filter((chunk) => /^\d+\.js$/.test(chunk))
172-
.map((chunk) => {
173-
console.log(` - chunk ${chunk}`);
174-
return chunk.replace(/\.js$/, "");
175-
});
176-
177-
const updatedFileContent = fileContent.replace(
178-
"__webpack_require__.f.require = (chunkId, promises) => {",
179-
`__webpack_require__.f.require = (chunkId, promises) => {
180-
if (installedChunks[chunkId]) return;
181-
${chunks
182-
.map(
183-
(chunk) => `
184-
if (chunkId === ${chunk}) {
185-
installChunk(require("./chunks/${chunk}.js"));
186-
return;
187-
}
188-
`
189-
)
190-
.join("\n")}
191-
`
192-
);
193-
194-
writeFileSync(webpackRuntimeFile, updatedFileContent);
195-
}
196-
197155
function createFixRequiresESBuildPlugin(templateDir: string): Plugin {
198156
return {
199157
name: "replaceRelative",
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { readFile } from "node:fs/promises";
2+
3+
import { expect, test, describe } from "vitest";
4+
5+
import { getChunkInstallationIdentifiers } from "./get-chunk-installation-identifiers";
6+
import { getWebpackChunksFileTsSource } from "./get-webpack-chunks-file-ts-source";
7+
8+
describe("getChunkInstallationIdentifiers", () => {
9+
test("the solution works as expected on unminified code", async () => {
10+
const fileContent = await readFile(
11+
`${import.meta.dirname}/test-fixtures/unminified-webpacks-file.js`,
12+
"utf8"
13+
);
14+
const tsSourceFile = getWebpackChunksFileTsSource(fileContent);
15+
const { installChunk, installedChunks } = await getChunkInstallationIdentifiers(tsSourceFile);
16+
expect(installChunk).toEqual("installChunk");
17+
expect(installedChunks).toEqual("installedChunks");
18+
});
19+
20+
test("the solution works as expected on minified code", async () => {
21+
const fileContent = await readFile(
22+
`${import.meta.dirname}/test-fixtures/minified-webpacks-file.js`,
23+
"utf8"
24+
);
25+
const tsSourceFile = getWebpackChunksFileTsSource(fileContent);
26+
const { installChunk, installedChunks } = await getChunkInstallationIdentifiers(tsSourceFile);
27+
expect(installChunk).toEqual("r");
28+
expect(installedChunks).toEqual("e");
29+
});
30+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import * as ts from "ts-morph";
2+
3+
export async function getChunkInstallationIdentifiers(sourceFile: ts.SourceFile): Promise<{
4+
installedChunks: string;
5+
installChunk: string;
6+
}> {
7+
const installChunkDeclaration = getInstallChunkDeclaration(sourceFile);
8+
const installedChunksDeclaration = getInstalledChunksDeclaration(sourceFile, installChunkDeclaration);
9+
10+
return {
11+
installChunk: installChunkDeclaration.getName(),
12+
installedChunks: installedChunksDeclaration.getName(),
13+
};
14+
}
15+
16+
function getInstallChunkDeclaration(sourceFile: ts.SourceFile) {
17+
const installChunkDeclaration = sourceFile
18+
.getDescendantsOfKind(ts.SyntaxKind.VariableDeclaration)
19+
.find((declaration) => {
20+
const arrowFunction = declaration.getInitializerIfKind(ts.SyntaxKind.ArrowFunction);
21+
// we're looking for an arrow function
22+
if (!arrowFunction) return false;
23+
24+
const functionParameters = arrowFunction.getParameters();
25+
// the arrow function we're looking for has a single parameter (the chunkId)
26+
if (functionParameters.length !== 1) return false;
27+
28+
const arrowFunctionBodyBlock = arrowFunction.getFirstChildByKind(ts.SyntaxKind.Block);
29+
30+
// the arrow function we're looking for has a block body
31+
if (!arrowFunctionBodyBlock) return false;
32+
33+
const statementKinds = arrowFunctionBodyBlock.getStatements().map((statement) => statement.getKind());
34+
35+
// the function we're looking for has 2 for loops (a standard one and a for-in one)
36+
const forInStatements = statementKinds.filter((s) => s === ts.SyntaxKind.ForInStatement);
37+
const forStatements = statementKinds.filter((s) => s === ts.SyntaxKind.ForStatement);
38+
if (forInStatements.length !== 1 || forStatements.length !== 1) return false;
39+
40+
// the function we're looking for accesses its parameter three times, and it
41+
// accesses its `modules`, `ids` and `runtime` properties (in this order)
42+
const parameterName = functionParameters[0].getText();
43+
const functionParameterAccessedProperties = arrowFunctionBodyBlock
44+
.getDescendantsOfKind(ts.SyntaxKind.PropertyAccessExpression)
45+
.filter(
46+
(propertyAccessExpression) => propertyAccessExpression.getExpression().getText() === parameterName
47+
)
48+
.map((propertyAccessExpression) => propertyAccessExpression.getName());
49+
if (functionParameterAccessedProperties.join(", ") !== "modules, ids, runtime") return false;
50+
51+
return true;
52+
});
53+
54+
if (!installChunkDeclaration) {
55+
throw new Error("ERROR: unable to find the installChunk function declaration");
56+
}
57+
58+
return installChunkDeclaration;
59+
}
60+
61+
function getInstalledChunksDeclaration(
62+
sourceFile: ts.SourceFile,
63+
installChunkDeclaration: ts.VariableDeclaration
64+
) {
65+
const allVariableDeclarations = sourceFile.getDescendantsOfKind(ts.SyntaxKind.VariableDeclaration);
66+
const installChunkDeclarationIdx = allVariableDeclarations.findIndex(
67+
(declaration) => declaration === installChunkDeclaration
68+
);
69+
70+
// the installedChunks declaration is comes right before the installChunk one
71+
const installedChunksDeclaration = allVariableDeclarations[installChunkDeclarationIdx - 1];
72+
73+
if (!installedChunksDeclaration?.getInitializer()?.isKind(ts.SyntaxKind.ObjectLiteralExpression)) {
74+
throw new Error("ERROR: unable to find the installedChunks declaration");
75+
}
76+
return installedChunksDeclaration;
77+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { readFile } from "node:fs/promises";
2+
3+
import { expect, test, describe } from "vitest";
4+
5+
import { getFileContentWithUpdatedWebpackFRequireCode } from "./get-file-content-with-updated-webpack-require-f-code";
6+
import { getWebpackChunksFileTsSource } from "./get-webpack-chunks-file-ts-source";
7+
8+
describe("getFileContentWithUpdatedWebpackFRequireCode", () => {
9+
test("the solution works as expected on unminified code", async () => {
10+
const fileContent = await readFile(
11+
`${import.meta.dirname}/test-fixtures/unminified-webpacks-file.js`,
12+
"utf8"
13+
);
14+
const tsSourceFile = getWebpackChunksFileTsSource(fileContent);
15+
const updatedFCode = await getFileContentWithUpdatedWebpackFRequireCode(
16+
tsSourceFile,
17+
{ installChunk: "installChunk", installedChunks: "installedChunks" },
18+
["658"]
19+
);
20+
expect(unstyleCode(updatedFCode)).toContain(`if (installedChunks[chunkId]) return;`);
21+
expect(unstyleCode(updatedFCode)).toContain(
22+
`if (chunkId === 658) return installChunk(require("./chunks/658.js"));`
23+
);
24+
});
25+
26+
test("the solution works as expected on minified code", async () => {
27+
const fileContent = await readFile(
28+
`${import.meta.dirname}/test-fixtures/minified-webpacks-file.js`,
29+
"utf8"
30+
);
31+
const tsSourceFile = getWebpackChunksFileTsSource(fileContent);
32+
const updatedFCode = await getFileContentWithUpdatedWebpackFRequireCode(
33+
tsSourceFile,
34+
{ installChunk: "r", installedChunks: "e" },
35+
["658"]
36+
);
37+
expect(unstyleCode(updatedFCode)).toContain("if (e[o]) return;");
38+
expect(unstyleCode(updatedFCode)).toContain(`if (o === 658) return r(require("./chunks/658.js"));`);
39+
});
40+
});
41+
42+
function unstyleCode(text: string): string {
43+
return text.replace(/\n\s+/g, "\n").replace(/\n/g, " ");
44+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as ts from "ts-morph";
2+
3+
export async function getFileContentWithUpdatedWebpackFRequireCode(
4+
sourceFile: ts.SourceFile,
5+
{ installChunk, installedChunks }: { installChunk: string; installedChunks: string },
6+
chunks: string[]
7+
): Promise<string> {
8+
const webpackFRequireFunction = sourceFile
9+
.getDescendantsOfKind(ts.SyntaxKind.BinaryExpression)
10+
.map((binaryExpression) => {
11+
const binaryExpressionLeft = binaryExpression.getLeft();
12+
if (!binaryExpressionLeft.getText().endsWith(".f.require")) return;
13+
14+
const binaryExpressionOperator = binaryExpression.getOperatorToken();
15+
if (binaryExpressionOperator.getText() !== "=") return;
16+
17+
const binaryExpressionRight = binaryExpression.getRight();
18+
const binaryExpressionRightText = binaryExpressionRight.getText();
19+
20+
if (
21+
!binaryExpressionRightText.includes(installChunk) ||
22+
!binaryExpressionRightText.includes(installedChunks)
23+
)
24+
return;
25+
26+
if (!binaryExpressionRight.isKind(ts.SyntaxKind.ArrowFunction)) return;
27+
28+
const arrowFunctionBody = binaryExpressionRight.getBody();
29+
if (!arrowFunctionBody.isKind(ts.SyntaxKind.Block)) return;
30+
31+
const arrowFunction = binaryExpressionRight;
32+
const functionParameters = arrowFunction.getParameters();
33+
if (functionParameters.length !== 2) return;
34+
35+
const callsInstallChunk = arrowFunctionBody
36+
.getDescendantsOfKind(ts.SyntaxKind.CallExpression)
37+
.some((callExpression) => callExpression.getExpression().getText() === installChunk);
38+
if (!callsInstallChunk) return;
39+
40+
const functionFirstParameterName = functionParameters[0]?.getName();
41+
const accessesInstalledChunksUsingItsFirstParameter = arrowFunctionBody
42+
.getDescendantsOfKind(ts.SyntaxKind.ElementAccessExpression)
43+
.some((elementAccess) => {
44+
return (
45+
elementAccess.getExpression().getText() === installedChunks &&
46+
elementAccess.getArgumentExpression()?.getText() === functionFirstParameterName
47+
);
48+
});
49+
if (!accessesInstalledChunksUsingItsFirstParameter) return;
50+
51+
return arrowFunction;
52+
})
53+
.find(Boolean);
54+
55+
if (!webpackFRequireFunction) {
56+
throw new Error("ERROR: unable to find the webpack f require function declaration");
57+
}
58+
59+
const functionParameterNames = webpackFRequireFunction
60+
.getParameters()
61+
.map((parameter) => parameter.getName());
62+
const chunkId = functionParameterNames[0];
63+
64+
const functionBody = webpackFRequireFunction.getBody() as ts.Block;
65+
66+
functionBody.insertStatements(0, [
67+
`if (${installedChunks}[${chunkId}]) return;`,
68+
...chunks.map(
69+
(chunk) => `\nif(${chunkId} === ${chunk}) return ${installChunk}(require("./chunks/${chunk}.js"));`
70+
),
71+
]);
72+
73+
return sourceFile.print();
74+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { readFile } from "node:fs/promises";
2+
3+
import { expect, test } from "vitest";
4+
5+
import { getUpdatedWebpackChunksFileContent } from "./get-updated-webpack-chunks-file-content";
6+
7+
test("the solution works as expected on unminified code", async () => {
8+
const fileContent = await readFile(
9+
`${import.meta.dirname}/test-fixtures/unminified-webpacks-file.js`,
10+
"utf8"
11+
);
12+
const updatedContent = await getUpdatedWebpackChunksFileContent(fileContent, ["658"]);
13+
expect(updatedContent).toMatchFileSnapshot("./test-snapshots/unminified-webpacks-file.js");
14+
});
15+
16+
test("the solution works as expected on minified code", async () => {
17+
const fileContent = await readFile(
18+
`${import.meta.dirname}/test-fixtures/minified-webpacks-file.js`,
19+
"utf8"
20+
);
21+
const updatedContent = await getUpdatedWebpackChunksFileContent(fileContent, ["658"]);
22+
expect(updatedContent).toMatchFileSnapshot("./test-snapshots/minified-webpacks-file.js");
23+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as ts from "ts-morph";
2+
3+
import { getChunkInstallationIdentifiers } from "./get-chunk-installation-identifiers";
4+
import { getFileContentWithUpdatedWebpackFRequireCode } from "./get-file-content-with-updated-webpack-require-f-code";
5+
import { getWebpackChunksFileTsSource } from "./get-webpack-chunks-file-ts-source";
6+
7+
export async function getUpdatedWebpackChunksFileContent(
8+
fileContent: string,
9+
chunks: string[]
10+
): Promise<string> {
11+
const tsSourceFile = getWebpackChunksFileTsSource(fileContent);
12+
13+
const chunkInstallationIdentifiers = await getChunkInstallationIdentifiers(tsSourceFile);
14+
15+
const updatedFileContent = getFileContentWithUpdatedWebpackFRequireCode(
16+
tsSourceFile,
17+
chunkInstallationIdentifiers,
18+
chunks
19+
);
20+
21+
return updatedFileContent;
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import * as ts from "ts-morph";
2+
3+
export function getWebpackChunksFileTsSource(fileContent: string): ts.SourceFile {
4+
const project = new ts.Project({
5+
compilerOptions: {
6+
target: ts.ScriptTarget.ES2023,
7+
lib: ["ES2023"],
8+
module: ts.ModuleKind.CommonJS,
9+
moduleResolution: ts.ModuleResolutionKind.NodeNext,
10+
allowJs: true,
11+
},
12+
});
13+
14+
const sourceFile = project.createSourceFile("webpack-runtime.js", fileContent);
15+
16+
return sourceFile;
17+
}

0 commit comments

Comments
 (0)