Skip to content

Instantly share code, notes, and snippets.

@mizchi
Last active June 6, 2020 09:17
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mizchi/368c75fc1088dabfda7dfed726ef9f85 to your computer and use it in GitHub Desktop.
Save mizchi/368c75fc1088dabfda7dfed726ef9f85 to your computer and use it in GitHub Desktop.
// small bundler
import path from "path";
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import * as t from "@babel/types";
import type { IPromisesAPI } from "memfs/lib/promises";
import createFs from "memfs/lib/promises";
import { vol } from "memfs";
// helper
function createMemoryFs(files: { [k: string]: string }): IPromisesAPI {
vol.fromJSON(files, "/");
return createFs(vol) as IPromisesAPI;
}
type Module = {
ast: t.File;
filepath: string;
imports: Import[];
};
type Import = {
filepath: string;
};
type Output = {
filepath: string;
code: string;
imports: Import[];
};
class Bundler {
public modulesMap = new Map<string, Module>();
public outModules: Array<Output> = [];
fs: IPromisesAPI;
constructor(public files: { [k: string]: string }) {
this.fs = createMemoryFs(files);
}
public async bundle(entry: string) {
await this.addModule(entry);
await this.transform(entry);
return await this.emit("/index.js");
}
async addModule(filepath: string) {
if (this.modulesMap.has(filepath)) {
return;
}
const basepath = path.dirname(filepath);
const code = (await this.fs.readFile(filepath, {
encoding: "utf-8",
})) as string;
const ast = parse(code, {
sourceFilename: filepath,
sourceType: "module",
});
let imports: Import[] = [];
traverse(ast, {
ImportDeclaration(nodePath) {
const target = nodePath.node.source.value;
const absPath = path.join(basepath, target);
imports.push({
filepath: absPath,
});
},
});
await Promise.all(
imports.map((imp) => {
return this.addModule(imp.filepath);
})
);
this.modulesMap.set(filepath, {
filepath,
ast,
imports,
});
}
async transform(filepath: string) {
const mod = this.modulesMap.get(filepath)!;
const alreadyIncluded = this.outModules.find(
(m) => m.filepath === filepath
);
if (alreadyIncluded) {
return;
}
const basepath = path.dirname(filepath);
const newImportStmts: t.Statement[] = [];
traverse(mod.ast, {
ImportDeclaration(nodePath) {
const target = nodePath.node.source.value;
const absPath = path.join(basepath, target);
const names: [string, string][] = [];
nodePath.node.specifiers.forEach((n) => {
if (n.type === "ImportDefaultSpecifier") {
names.push(["default", n.local.name]);
}
if (n.type === "ImportSpecifier") {
names.push([n.imported.name, n.local.name]);
}
if (n.type === "ImportNamespaceSpecifier") {
newImportStmts.push(
t.variableDeclaration("const", [
t.variableDeclarator(
t.identifier(n.local.name),
t.callExpression(t.identifier("$$import"), [
t.stringLiteral(absPath),
])
),
])
);
}
});
const newNode = t.variableDeclaration("const", [
t.variableDeclarator(
t.objectPattern(
names.map(([imported, local]) => {
return t.objectProperty(
t.identifier(imported),
t.identifier(local)
);
})
),
t.callExpression(t.identifier("$$import"), [
t.stringLiteral(absPath),
])
),
]);
newImportStmts.push(newNode);
nodePath.replaceWith(t.emptyStatement());
},
ExportDefaultDeclaration(nodePath) {
const name = "default";
const right = nodePath.node.declaration as any;
const newNode = t.expressionStatement(
t.assignmentExpression(
"=",
t.memberExpression(
t.identifier("$$exports"),
t.stringLiteral(name),
true
),
right
)
);
nodePath.replaceWith(newNode);
},
ExportNamedDeclaration(nodePath) {
// TODO: name mapping
// TODO: Export multiple name
if (nodePath.node.declaration) {
const decl = nodePath.node.declaration.declarations[0];
const name = decl.id.name;
const right = decl.init;
const newNode = t.expressionStatement(
t.assignmentExpression(
"=",
t.memberExpression(
t.identifier("$$exports"),
t.identifier(name)
// true
),
right
)
);
nodePath.replaceWith(newNode);
} else {
// export { a as b }
const exportNames: Array<{ exported: string; imported: string }> = [];
for (const specifier of nodePath.node.specifiers) {
if (specifier.type == "ExportSpecifier") {
exportNames.push({
exported: specifier.exported.name,
imported: specifier.local.name,
});
}
}
nodePath.replaceWith(
t.blockStatement(
exportNames.map((exp) => {
return t.expressionStatement(
t.assignmentExpression(
"=",
t.memberExpression(
t.identifier("$$exports"),
t.identifier(exp.exported)
// true
),
nodePath.node.source
? t.memberExpression(
t.callExpression(t.identifier("$$import"), [
t.stringLiteral(
path.join(basepath, nodePath.node.source.value)
),
]),
t.identifier(exp.imported)
)
: t.identifier(exp.imported)
)
);
})
)
);
}
},
});
const out = {
...mod.ast,
program: {
...mod.ast.program,
body: [...newImportStmts, ...mod.ast.program.body],
},
};
const gen = generate(out);
this.outModules.push({
imports: mod.imports,
filepath: mod.filepath,
code: gen.code,
});
await Promise.all(
mod.imports.map((imp) => {
return this.transform(imp.filepath);
})
);
}
async emit(entry: string) {
const entryMod = this.outModules.find((m) => m.filepath === entry);
const importCodes = this.outModules
.filter((m) => m.filepath !== entry)
.map((m) => {
return `$$import("${m.filepath}");`;
})
.join("\n");
const mods = this.outModules
.filter((m) => m.filepath !== entry)
.map((m) => {
return `"${m.filepath}": ($$exports) => {
${m.code}
return $$exports;
}
`;
})
.join(",");
return `// minibundle generate
const $$exported = {};
const $$modules = { ${mods} };
function $$import(id){
if ($$exported[id]) {
return $$exported[id];
}
$$exported[id] = {};
$$modules[id]($$exported[id]);
return $$exported[id];
}
// evaluate as static module
${importCodes};
// -- runner --
const $$exports = {}; // dummy
${entryMod?.code};
`;
}
}
// runtime
const files = {
"/bar.js": `
import foo from "./foo.js";
console.log("eval bar once")
export default "bar$" + foo
`,
"/foo.js": `
console.log("eval foo once")
export default "foo$default";
export const b = "b";
export const a = "a";
`,
"/index.js": `
import * as t from "./foo.js";
import foo, {a, b as c} from "./foo.js";
import bar from "./bar.js";
export const x = c;
export default 1;
console.log(foo, bar);
`,
};
// @ts-ignore
import prettier from "prettier";
async function main() {
const bundler = new Bundler(files);
const built = await bundler.bundle("/index.js");
console.log(prettier.format(built));
console.log("--- eval ----");
eval(built);
}
main();
// minibundle generate
const $$exported = {};
const $$modules = {
"/foo.js": ($$exports) => {
console.log("eval foo once");
$$exports["default"] = "foo$default";
$$exports["b"] = "b";
$$exports["a"] = "a";
return $$exports;
},
"/bar.js": ($$exports) => {
const { default: foo } = $$import("/foo.js");
console.log("eval bar once");
$$exports["default"] = "bar$" + foo;
return $$exports;
},
};
function $$import(id) {
if ($$exported[id]) {
return $$exported[id];
}
$$exported[id] = {};
$$modules[id]($$exported[id]);
return $$exported[id];
}
// additional code
globalThis.$$import = $$import;
// evaluate as static module
$$import("/foo.js");
$$import("/bar.js");
// -- runner --
const $$exports = {}; // dummy
const { default: foo, a: a, b: c } = $$import("/foo.js");
const { default: bar } = $$import("/bar.js");
$$exports["x"] = c;
$$exports["default"] = 1;
console.log(foo, bar);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment