Skip to content

Commit 1ae1f4c

Browse files
authored
feat: inline cjs index (#1113)
* feat: inline cjs index * pin esbuild
1 parent 57e6be9 commit 1ae1f4c

File tree

5 files changed

+601
-0
lines changed

5 files changed

+601
-0
lines changed

.changeset/rude-spiders-run.md

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@typescript-eslint/parser": "4.30.0",
4040
"chai": "^4.2.0",
4141
"chai-as-promised": "^7.1.1",
42+
"esbuild": "0.19.11",
4243
"eslint": "7.32.0",
4344
"eslint-config-prettier": "8.3.0",
4445
"eslint-plugin-prettier": "3.4.1",

scripts/compilation/Inliner.js

+316
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
const fs = require("fs");
2+
const path = require("path");
3+
const { spawnProcess } = require("./../utils/spawn-process");
4+
const walk = require("./../utils/walk");
5+
const esbuild = require("esbuild");
6+
7+
const root = path.join(__dirname, "..", "..");
8+
9+
/**
10+
*
11+
* Inline a package as one dist file, preserves other files as re-export stubs,
12+
* preserves files with react-native variants as externals.
13+
*
14+
*/
15+
module.exports = class Inliner {
16+
constructor(pkg) {
17+
this.package = pkg;
18+
this.platform = "node";
19+
this.isPackage = fs.existsSync(path.join(root, "packages", pkg));
20+
this.isLib = fs.existsSync(path.join(root, "lib", pkg));
21+
this.isClient = !this.isPackage && !this.isLib;
22+
this.subfolder = this.isPackage ? "packages" : this.isLib ? "lib" : "clients";
23+
24+
this.packageDirectory = path.join(root, this.subfolder, pkg);
25+
26+
this.outfile = path.join(root, this.subfolder, pkg, "dist-cjs", "index.js");
27+
28+
this.pkgJson = require(path.join(root, this.subfolder, this.package, "package.json"));
29+
/**
30+
* If the react entrypoint is another file entirely, then bail out of inlining.
31+
*/
32+
this.bailout = typeof this.pkgJson["react-native"] === "string";
33+
}
34+
35+
/**
36+
* step 0: delete the dist-cjs folder.
37+
*/
38+
async clean() {
39+
await spawnProcess("yarn", ["rimraf", "./dist-cjs", "tsconfig.cjs.tsbuildinfo"], { cwd: this.packageDirectory });
40+
console.log("Deleted ./dist-cjs in " + this.package);
41+
return this;
42+
}
43+
44+
/**
45+
* step 1: build the default tsc dist-cjs output with dispersed files.
46+
* we will need the files to be in place for stubbing.
47+
*/
48+
async tsc() {
49+
await spawnProcess("yarn", ["g:tsc", "-p", "tsconfig.cjs.json"], { cwd: this.packageDirectory });
50+
console.log("Finished recompiling ./dist-cjs in " + this.package);
51+
return this;
52+
}
53+
54+
/**
55+
* step 2: detect all variant files and their transitive local imports.
56+
* these files will not be inlined, in order to preserve the react-native dist-cjs file replacement behavior.
57+
*/
58+
async discoverVariants() {
59+
if (this.bailout) {
60+
return this;
61+
}
62+
this.variantEntries = Object.entries(this.pkgJson["react-native"] ?? {});
63+
64+
for await (const file of walk(path.join(this.packageDirectory, "dist-cjs"))) {
65+
if (file.endsWith(".js") && fs.existsSync(file.replace(/\.js$/, ".native.js"))) {
66+
console.log("detected undeclared auto-variant", file);
67+
const canonical = file.replace(/(.*?)dist-cjs\//, "./dist-cjs/").replace(/\.js$/, "");
68+
const variant = canonical.replace(/(.*?)(\.js)?$/, "$1.native$2");
69+
70+
this.variantEntries.push([canonical, variant]);
71+
}
72+
if (fs.existsSync(file.replace(/\.js$/, ".browser.js"))) {
73+
// not applicable to CJS?
74+
}
75+
}
76+
77+
this.transitiveVariants = [];
78+
79+
for (const [k, v] of this.variantEntries) {
80+
for (const variantFile of [k, String(v)]) {
81+
if (!variantFile.includes("dist-cjs/")) {
82+
continue;
83+
}
84+
const keyFile = path.join(
85+
this.packageDirectory,
86+
"dist-cjs",
87+
variantFile.replace(/(.*?)dist-cjs\//, "") + (variantFile.endsWith(".js") ? "" : ".js")
88+
);
89+
const keyFileContents = fs.readFileSync(keyFile, "utf-8");
90+
const requireStatements = keyFileContents.matchAll(/require\("(.*?)"\)/g);
91+
for (const requireStatement of requireStatements) {
92+
if (requireStatement[1]?.startsWith(".")) {
93+
// is relative import.
94+
const key = path
95+
.normalize(path.join(path.dirname(keyFile), requireStatement[1]))
96+
.replace(/(.*?)dist-cjs\//, "./dist-cjs/");
97+
console.log("Transitive variant file:", key);
98+
99+
const transitiveVariant = key.replace(/(.*?)dist-cjs\//, "").replace(/(\.js)?$/, "");
100+
101+
if (!this.transitiveVariants.includes(transitiveVariant)) {
102+
this.variantEntries.push([key, key]);
103+
this.transitiveVariants.push(transitiveVariant);
104+
}
105+
}
106+
}
107+
}
108+
}
109+
110+
this.variantExternals = [];
111+
this.variantMap = {};
112+
113+
for (const [k, v] of this.variantEntries) {
114+
const prefix = "dist-cjs/";
115+
const keyPrefixIndex = k.indexOf(prefix);
116+
if (keyPrefixIndex === -1) {
117+
continue;
118+
}
119+
const keyRelativePath = k.slice(keyPrefixIndex + prefix.length);
120+
const valuePrefixIndex = String(v).indexOf(prefix);
121+
122+
const addJsExtension = (file) => (file.endsWith(".js") ? file : file + ".js");
123+
124+
if (valuePrefixIndex !== -1) {
125+
const valueRelativePath = String(v).slice(valuePrefixIndex + prefix.length);
126+
this.variantExternals.push(...[keyRelativePath, valueRelativePath].map(addJsExtension));
127+
this.variantMap[keyRelativePath] = valueRelativePath;
128+
} else {
129+
this.variantExternals.push(addJsExtension(keyRelativePath));
130+
this.variantMap[keyRelativePath] = v;
131+
}
132+
}
133+
134+
return this;
135+
}
136+
137+
/**
138+
* step 3: bundle the package index into dist-cjs/index.js except for node_modules
139+
* and also excluding any local files that have variants for react-native.
140+
*/
141+
async bundle() {
142+
if (this.bailout) {
143+
return this;
144+
}
145+
146+
this.variantExternalsForEsBuild = this.variantExternals.map(
147+
(variant) => "*/" + path.basename(variant).replace(/.js$/, "")
148+
);
149+
150+
await esbuild.build({
151+
platform: this.platform,
152+
bundle: true,
153+
format: "cjs",
154+
mainFields: ["main"],
155+
allowOverwrite: true,
156+
entryPoints: [path.join(root, this.subfolder, this.package, "src", "index.ts")],
157+
outfile: this.outfile,
158+
keepNames: true,
159+
packages: "external",
160+
external: [...this.variantExternalsForEsBuild],
161+
});
162+
return this;
163+
}
164+
165+
/**
166+
* step 4: rewrite all existing dist-cjs files except the index.js file.
167+
* These now become re-exports of the index to preserve deep-import behavior.
168+
*/
169+
async rewriteStubs() {
170+
if (this.bailout) {
171+
return this;
172+
}
173+
174+
for await (const file of walk(path.join(this.packageDirectory, "dist-cjs"))) {
175+
const relativePath = file.replace(path.join(this.packageDirectory, "dist-cjs"), "").slice(1);
176+
177+
if (!file.endsWith(".js")) {
178+
console.log("Skipping", path.basename(file), "file extension is not .js.");
179+
continue;
180+
}
181+
182+
if (relativePath === "index.js") {
183+
console.log("Skipping index.js");
184+
continue;
185+
}
186+
187+
if (this.variantExternals.find((external) => relativePath.endsWith(external))) {
188+
console.log("Not rewriting.", relativePath, "is variant.");
189+
continue;
190+
}
191+
192+
console.log("Rewriting", relativePath, "as index re-export stub.");
193+
194+
const depth = relativePath.split("/").length - 1;
195+
const indexRelativePath =
196+
(depth === 0
197+
? "."
198+
: Array.from({ length: depth })
199+
.map(() => "..")
200+
.join("/")) + "/index.js";
201+
202+
fs.writeFileSync(file, `module.exports = require("${indexRelativePath}");`);
203+
}
204+
205+
return this;
206+
}
207+
208+
/**
209+
* step 5: rewrite variant external imports to correct path.
210+
* these externalized variants use relative imports for transitive variant files
211+
* which need to be rewritten when in the index.js file.
212+
*/
213+
async fixVariantImportPaths() {
214+
if (this.bailout) {
215+
return this;
216+
}
217+
this.indexContents = fs.readFileSync(this.outfile, "utf-8");
218+
for (const variant of Object.keys(this.variantMap)) {
219+
const basename = path.basename(variant).replace(/.js$/, "");
220+
const dirname = path.dirname(variant);
221+
222+
const find = new RegExp(`require\\("\\.(.*?)/${basename}"\\)`, "g");
223+
const replace = `require("./${dirname}/${basename}")`;
224+
225+
this.indexContents = this.indexContents.replace(find, replace);
226+
227+
console.log("Replacing", find, "with", replace);
228+
}
229+
230+
fs.writeFileSync(this.outfile, this.indexContents, "utf-8");
231+
return this;
232+
}
233+
234+
/**
235+
* Step 5.5, dedupe imported externals.
236+
*/
237+
async dedupeExternals() {
238+
if (this.bailout) {
239+
return this;
240+
}
241+
const redundantRequireStatements = this.indexContents.matchAll(
242+
/var import_([a-z_]+)(\d+) = require\("([@a-z\/-0-9]+)"\);/g
243+
);
244+
for (const requireStatement of redundantRequireStatements) {
245+
const variableSuffix = requireStatement[1];
246+
const packageName = requireStatement[3].replace("/", "\\/");
247+
248+
const original = this.indexContents.match(
249+
new RegExp(`var import_${variableSuffix} = require\\(\"${packageName}\"\\);`)
250+
);
251+
if (original) {
252+
let redundancyIndex = 0;
253+
let misses = 0;
254+
255+
// perform an incremental replacement instead of a global (\d+) replacement
256+
// to be safe.
257+
while (true) {
258+
const redundantRequire = `var import_${variableSuffix}${redundancyIndex} = require\\("${packageName}"\\);`;
259+
const redundantVariable = `import_${variableSuffix}${redundancyIndex}`;
260+
261+
if (this.indexContents.match(new RegExp(redundantRequire))) {
262+
console.log("Replacing var", redundantVariable);
263+
this.indexContents = this.indexContents
264+
.replace(new RegExp(redundantRequire, "g"), "")
265+
.replace(new RegExp(redundantVariable, "g"), `import_${variableSuffix}`);
266+
} else if (misses++ > 10) {
267+
break;
268+
}
269+
redundancyIndex++;
270+
}
271+
}
272+
}
273+
fs.writeFileSync(this.outfile, this.indexContents, "utf-8");
274+
return this;
275+
}
276+
277+
/**
278+
* step 6: we validate that the index.js file has a require statement
279+
* for any variant files, to ensure they are not in the inlined (bundled) index.
280+
*/
281+
async validate() {
282+
if (this.bailout) {
283+
return this;
284+
}
285+
this.indexContents = fs.readFileSync(this.outfile, "utf-8");
286+
287+
const externalsToCheck = new Set(
288+
Object.keys(this.variantMap)
289+
.filter((variant) => !this.transitiveVariants.includes(variant) && !variant.endsWith("index"))
290+
.map((variant) => path.basename(variant).replace(/.js$/, ""))
291+
);
292+
293+
for (const line of this.indexContents.split("\n")) {
294+
// we expect to see a line with require() and the variant external in it
295+
if (line.includes("require(")) {
296+
const checkOrder = [...externalsToCheck].sort().reverse();
297+
for (const external of checkOrder) {
298+
if (line.includes(external)) {
299+
console.log("Inline index confirmed require() for variant external:", external);
300+
externalsToCheck.delete(external);
301+
continue;
302+
}
303+
}
304+
}
305+
}
306+
307+
if (externalsToCheck.size) {
308+
throw new Error(
309+
"require() statements for the following variant externals: " +
310+
[...externalsToCheck].join(", ") +
311+
" were not found in the index."
312+
);
313+
}
314+
return this;
315+
}
316+
};

scripts/inline.js

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
*
3+
* Inline a package as one dist file.
4+
*
5+
*/
6+
7+
const fs = require("fs");
8+
const path = require("path");
9+
const Inliner = require("./compilation/Inliner");
10+
11+
const root = path.join(__dirname, "..");
12+
13+
const package = process.argv[2];
14+
15+
if (!package) {
16+
/**
17+
* If no package is selected, this script sets all build:cjs scripts to
18+
* use this inliner script instead of only tsc.
19+
*/
20+
const packages = fs.readdirSync(path.join(root, "packages"));
21+
for (const pkg of packages) {
22+
const pkgJsonFilePath = path.join(root, "packages", pkg, "package.json");
23+
const pkgJson = require(pkgJsonFilePath);
24+
25+
pkgJson.scripts["build:cjs"] = `node ../../scripts/inline ${pkg}`;
26+
fs.writeFileSync(pkgJsonFilePath, JSON.stringify(pkgJson, null, 2));
27+
}
28+
} else {
29+
(async () => {
30+
const inliner = new Inliner(package);
31+
await inliner.clean();
32+
await inliner.tsc();
33+
await inliner.discoverVariants();
34+
await inliner.bundle();
35+
await inliner.rewriteStubs();
36+
await inliner.fixVariantImportPaths();
37+
await inliner.dedupeExternals();
38+
await inliner.validate();
39+
})();
40+
}

0 commit comments

Comments
 (0)