Skip to content

Commit e3c865c

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 87e4032 commit e3c865c

16 files changed

+1579
-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: 8 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": [
@@ -33,6 +35,10 @@
3335
"glob": "^11.0.0",
3436
"next": "14.2.5",
3537
"tsup": "^8.2.4",
36-
"typescript": "^5.5.4"
38+
"typescript": "^5.5.4",
39+
"vitest": "^2.1.1"
40+
},
41+
"dependencies": {
42+
"ts-morph": "^23.0.0"
3743
}
3844
}

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";
@@ -11,6 +11,7 @@ import { patchFindDir } from "./patches/to-investigate/patch-find-dir";
1111
import { inlineNextRequire } from "./patches/to-investigate/inline-next-require";
1212
import { inlineEvalManifest } from "./patches/to-investigate/inline-eval-manifest";
1313
import { patchWranglerDeps } from "./patches/to-investigate/wrangler-deps";
14+
import { updateWebpackChunksFile } from "./patches/investigated/update-webpack-chunks-file";
1415

1516
/**
1617
* Using the Next.js build output in the `.next` directory builds a workerd compatible output
@@ -155,49 +156,6 @@ async function updateWorkerBundledCode(
155156
await writeFile(workerOutputFile, patchedCode);
156157
}
157158

158-
/**
159-
* Fixes the webpack-runtime.js file by removing its webpack dynamic requires.
160-
*
161-
* This hack is especially bad for two reasons:
162-
* - it requires setting `experimental.serverMinification` to `false` in the app's config file
163-
* - indicates that files inside the output directory still get a hold of files from the outside: `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`
164-
* so this shows that not everything that's needed to deploy the application is in the output directory...
165-
*/
166-
async function updateWebpackChunksFile(nextjsAppPaths: NextjsAppPaths) {
167-
console.log("# updateWebpackChunksFile");
168-
const webpackRuntimeFile = `${nextjsAppPaths.standaloneAppServerDir}/webpack-runtime.js`;
169-
170-
console.log({ webpackRuntimeFile });
171-
172-
const fileContent = readFileSync(webpackRuntimeFile, "utf-8");
173-
174-
const chunks = readdirSync(`${nextjsAppPaths.standaloneAppServerDir}/chunks`)
175-
.filter((chunk) => /^\d+\.js$/.test(chunk))
176-
.map((chunk) => {
177-
console.log(` - chunk ${chunk}`);
178-
return chunk.replace(/\.js$/, "");
179-
});
180-
181-
const updatedFileContent = fileContent.replace(
182-
"__webpack_require__.f.require = (chunkId, promises) => {",
183-
`__webpack_require__.f.require = (chunkId, promises) => {
184-
if (installedChunks[chunkId]) return;
185-
${chunks
186-
.map(
187-
(chunk) => `
188-
if (chunkId === ${chunk}) {
189-
installChunk(require("./chunks/${chunk}.js"));
190-
return;
191-
}
192-
`
193-
)
194-
.join("\n")}
195-
`
196-
);
197-
198-
writeFileSync(webpackRuntimeFile, updatedFileContent);
199-
}
200-
201159
function createFixRequiresESBuildPlugin(templateDir: string): Plugin {
202160
return {
203161
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("gets chunk identifiers from 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("gets chunk identifiers from 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): ts.VariableDeclaration {
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+
): ts.VariableDeclaration {
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-f-require-code";
6+
import { getWebpackChunksFileTsSource } from "./get-webpack-chunks-file-ts-source";
7+
8+
describe("getFileContentWithUpdatedWebpackFRequireCode", () => {
9+
test("returns the updated content of the f.require function from unminified webpack runtime 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("returns the updated content of the f.require function from minified webpack runtime 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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
const functionUsesChunkInstallationVariables =
21+
binaryExpressionRightText.includes(installChunk) &&
22+
binaryExpressionRightText.includes(installedChunks);
23+
if (!functionUsesChunkInstallationVariables) return;
24+
25+
if (!binaryExpressionRight.isKind(ts.SyntaxKind.ArrowFunction)) return;
26+
27+
const arrowFunctionBody = binaryExpressionRight.getBody();
28+
if (!arrowFunctionBody.isKind(ts.SyntaxKind.Block)) return;
29+
30+
const arrowFunction = binaryExpressionRight;
31+
const functionParameters = arrowFunction.getParameters();
32+
if (functionParameters.length !== 2) return;
33+
34+
const callsInstallChunk = arrowFunctionBody
35+
.getDescendantsOfKind(ts.SyntaxKind.CallExpression)
36+
.some((callExpression) => callExpression.getExpression().getText() === installChunk);
37+
if (!callsInstallChunk) return;
38+
39+
const functionFirstParameterName = functionParameters[0]?.getName();
40+
const accessesInstalledChunksUsingItsFirstParameter = arrowFunctionBody
41+
.getDescendantsOfKind(ts.SyntaxKind.ElementAccessExpression)
42+
.some((elementAccess) => {
43+
return (
44+
elementAccess.getExpression().getText() === installedChunks &&
45+
elementAccess.getArgumentExpression()?.getText() === functionFirstParameterName
46+
);
47+
});
48+
if (!accessesInstalledChunksUsingItsFirstParameter) return;
49+
50+
return arrowFunction;
51+
})
52+
.find(Boolean);
53+
54+
if (!webpackFRequireFunction) {
55+
throw new Error("ERROR: unable to find the webpack f require function declaration");
56+
}
57+
58+
const functionParameterNames = webpackFRequireFunction
59+
.getParameters()
60+
.map((parameter) => parameter.getName());
61+
const chunkId = functionParameterNames[0];
62+
63+
const functionBody = webpackFRequireFunction.getBody() as ts.Block;
64+
65+
functionBody.insertStatements(0, [
66+
`if (${installedChunks}[${chunkId}]) return;`,
67+
...chunks.map(
68+
(chunk) => `\nif(${chunkId} === ${chunk}) return ${installChunk}(require("./chunks/${chunk}.js"));`
69+
),
70+
]);
71+
72+
return sourceFile.print();
73+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { readFile } from "node:fs/promises";
2+
3+
import { expect, test, describe } from "vitest";
4+
5+
import { getUpdatedWebpackChunksFileContent } from "./get-updated-webpack-chunks-file-content";
6+
7+
describe("getUpdatedWebpackChunksFileContent", () => {
8+
test("returns the updated content of a webpack runtime chunks unminified file", async () => {
9+
const fileContent = await readFile(
10+
`${import.meta.dirname}/test-fixtures/unminified-webpacks-file.js`,
11+
"utf8"
12+
);
13+
const updatedContent = await getUpdatedWebpackChunksFileContent(fileContent, ["658"]);
14+
expect(updatedContent).toMatchFileSnapshot("./test-snapshots/unminified-webpacks-file.js");
15+
});
16+
17+
test("returns the updated content of a webpack runtime chunks minified file", async () => {
18+
const fileContent = await readFile(
19+
`${import.meta.dirname}/test-fixtures/minified-webpacks-file.js`,
20+
"utf8"
21+
);
22+
const updatedContent = await getUpdatedWebpackChunksFileContent(fileContent, ["658"]);
23+
expect(updatedContent).toMatchFileSnapshot("./test-snapshots/minified-webpacks-file.js");
24+
});
25+
});
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-f-require-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)