|
// 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(); |