├── .editorconfig ├── .gitignore ├── .npmignore ├── README.md ├── dist ├── bundle.js ├── compiler.js ├── fetch.js ├── main.js └── transform.js ├── example ├── dynamic_import.ts ├── equal.ts ├── example.ts ├── hoge.ts ├── import_meta.ts ├── nodelib.ts ├── other.ts ├── react.tsx ├── server.ts └── some.ts ├── package.json ├── src ├── __tests__ │ ├── bundle.test.ts │ ├── fetch.test.ts │ └── transform.test.ts ├── bundle.ts ├── fetch.ts ├── main.ts └── transform.ts ├── template └── template.ts ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_size = 2 3 | 4 | [Makefile] 5 | indent_style = tab -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | tmp 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tmp 2 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm version](https://badge.fury.io/js/%40keroxp%2Ftsb.svg)](https://badge.fury.io/js/%40keroxp%2Ftsb) 2 | 3 | tsb 4 | === 5 | TypeScript/JavaScript module bundler for ESModule 6 | 7 | ## Description 8 | 9 | `tsb` is module bundler for ECMAScript. It bundles TypeScript/JavaScript modules built with pure ESModule. 10 | 11 | ## Concept 12 | 13 | - **TypeScript first** 14 | - tsb bundles and transpiles ts/js files with TypeScript Compiler API 15 | - **ESM only** 16 | - tsb only supports ECMAScript that are written with pure ESModule (import/export) 17 | - CommonJS,AMD (require/exports) are **NOT supported** 18 | - **URL import support** 19 | - tsb will automatically fetch URL import/export and bundles all dependencies and stores caches. 20 | 21 | ## Install 22 | Via yarn 23 | 24 | ```bash 25 | $ yarn global add @keroxp/tsb 26 | ``` 27 | 28 | Via npm 29 | 30 | ```bash 31 | $ npm i -g @keroxp/tsb 32 | ``` 33 | 34 | or 35 | 36 | ```bash 37 | $ npx @keroxp/tsb 38 | ``` 39 | ## Usage 40 | 41 | ```bash 42 | $ tsb ./example/server.ts > bundle.js 43 | ``` 44 | 45 | ## License 46 | 47 | MIT -------------------------------------------------------------------------------- /dist/bundle.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | // Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license. 4 | const path = require("path"); 5 | const url = require("url"); 6 | const transform_1 = require("./transform"); 7 | const fs = require("fs-extra"); 8 | const ts = require("typescript"); 9 | const fetch_1 = require("./fetch"); 10 | exports.kUriRegex = /^(https?):\/\/(.+?)$/; 11 | exports.kRelativeRegex = /^\.\.?\/.+?\.[jt]sx?$/; 12 | async function readFileAsync(file) { 13 | return String(await fs.readFile(file)); 14 | } 15 | async function fileExists(p) { 16 | return fs.pathExists(p); 17 | } 18 | async function resolveUri(id) { 19 | if (id.match(exports.kUriRegex)) { 20 | return fetch_1.urlToCacheFilePath(id); 21 | } 22 | else if (id.match(exports.kRelativeRegex)) { 23 | return path.resolve(id); 24 | } 25 | else { 26 | throw new Error("invalid module specifier: " + id); 27 | } 28 | } 29 | async function resolveModuleId(source, skipFetch = false) { 30 | if (source.dependency.match(exports.kUriRegex)) { 31 | // any + url 32 | const cachePath = fetch_1.urlToCacheFilePath(source.dependency); 33 | const cacheMetaPath = fetch_1.urlToCacheMetaFilePath(source.dependency); 34 | if (!(await fileExists(cachePath))) { 35 | if (!(await fileExists(cacheMetaPath))) { 36 | if (!skipFetch) { 37 | await fetch_1.fetchModule(source.dependency); 38 | return resolveModuleId(source, false); 39 | } 40 | else { 41 | throw createError(source, ` 42 | Cache file was not found in: ${cachePath}. 43 | `); 44 | } 45 | } 46 | const headers = await readFileAsync(cacheMetaPath); 47 | const meta = JSON.parse(headers); 48 | if (!meta.redirectTo) { 49 | throw new Error(`meta file for ${source.dependency} may be broken`); 50 | } 51 | return resolveModuleId({ 52 | moduleId: ".", 53 | dependency: meta.redirectTo 54 | }); 55 | } 56 | else { 57 | return source.dependency; 58 | } 59 | } 60 | else if (source.moduleId.match(exports.kUriRegex)) { 61 | // url + relative 62 | return resolveModuleId({ 63 | moduleId: ".", 64 | dependency: url.resolve(source.moduleId, source.dependency) 65 | }); 66 | } 67 | else { 68 | // relative + relative 69 | return joinModuleId(source); 70 | } 71 | } 72 | exports.resolveModuleId = resolveModuleId; 73 | async function traverseDependencyTree(sourceFile, dependencyTree, redirectionMap, opts) { 74 | const dependencies = []; 75 | let id; 76 | id = await resolveModuleId(sourceFile, opts.skipFetch); 77 | redirectionMap.set(joinModuleId(sourceFile), id); 78 | if (dependencyTree.has(id)) { 79 | return; 80 | } 81 | dependencyTree.set(id, sourceFile); 82 | const visit = (node) => { 83 | if (ts.isImportDeclaration(node)) { 84 | const dependency = node.moduleSpecifier.text; 85 | dependencies.push(dependency); 86 | } 87 | else if (ts.isCallExpression(node) && 88 | node.expression.kind === ts.SyntaxKind.ImportKeyword) { 89 | // import("aa").then(v => {}) 90 | const [module] = node.arguments; 91 | if (ts.isStringLiteral(module)) { 92 | dependencies.push(module.text); 93 | } 94 | } 95 | else if (ts.isExportDeclaration(node)) { 96 | const exportClause = node.exportClause; 97 | const module = node.moduleSpecifier; 98 | if (exportClause) { 99 | if (module) { 100 | // export {a,b} form "bb" 101 | dependencies.push(module.text); 102 | } 103 | else { 104 | // export {a,b 105 | } 106 | } 107 | else { 108 | dependencies.push(module.text); 109 | } 110 | } 111 | ts.forEachChild(node, visit); 112 | }; 113 | const resolvedPath = await resolveUri(id); 114 | const text = await readFileAsync(resolvedPath); 115 | const src = ts.createSourceFile(resolvedPath, text, ts.ScriptTarget.ESNext); 116 | ts.forEachChild(src, visit); 117 | for (const dependency of dependencies) { 118 | await traverseDependencyTree({ dependency: dependency, moduleId: id }, dependencyTree, redirectionMap, opts); 119 | } 120 | } 121 | function joinModuleId(source) { 122 | if (source.dependency.match(exports.kUriRegex)) { 123 | // url 124 | return source.dependency; 125 | } 126 | else if (source.moduleId.match(exports.kUriRegex)) { 127 | // url + relative 128 | return url.resolve(source.moduleId, source.dependency); 129 | } 130 | else if (source.dependency.match(exports.kRelativeRegex)) { 131 | // relative + relative 132 | const cwd = process.cwd(); 133 | const dir = path.dirname(source.moduleId); 134 | return "./" + path.relative(cwd, path.join(dir, source.dependency)); 135 | } 136 | else { 137 | throw createError(source, `dependency must be URL or start with ./ or ../`); 138 | } 139 | } 140 | exports.joinModuleId = joinModuleId; 141 | function createError(source, message) { 142 | return new Error(`moduleId: "${source.moduleId}", dependency: "${source.dependency}": ${message}`); 143 | } 144 | async function bundle(entry, opts) { 145 | const tree = new Map(); 146 | const redirectionMap = new Map(); 147 | let canonicalName; 148 | if (entry.match(exports.kUriRegex)) { 149 | canonicalName = entry; 150 | } 151 | else { 152 | canonicalName = "./" + path.relative(process.cwd(), entry); 153 | } 154 | await traverseDependencyTree({ 155 | dependency: canonicalName, 156 | moduleId: "." 157 | }, tree, redirectionMap, opts); 158 | const printer = ts.createPrinter(); 159 | let template = await readFileAsync(path.resolve(__dirname, "../template/template.ts")); 160 | const resolveModule = (moduleId, dep) => { 161 | const redirection = redirectionMap.get(moduleId); 162 | if (!redirection) { 163 | throw new Error(`${moduleId} not found in redirection map`); 164 | } 165 | if (dep.match(exports.kUriRegex)) { 166 | const ret = redirectionMap.get(dep); 167 | if (!ret) { 168 | throw new Error(`${dep} not found in redirection map`); 169 | } 170 | return ret; 171 | } 172 | else { 173 | return joinModuleId({ 174 | moduleId: moduleId, 175 | dependency: dep 176 | }); 177 | } 178 | }; 179 | const modules = []; 180 | for (const [moduleId] of tree.entries()) { 181 | const transformer = new transform_1.Transformer(moduleId, resolveModule); 182 | let text = await readFileAsync(await resolveUri(moduleId)); 183 | if (text.startsWith("#!")) { 184 | // disable shell 185 | text = "//" + text; 186 | } 187 | const src = ts.createSourceFile(moduleId, text, ts.ScriptTarget.ESNext); 188 | const result = ts.transform(src, transformer.transformers()); 189 | const transformed = printer.printFile(result 190 | .transformed[0]); 191 | const opts = { 192 | target: ts.ScriptTarget.ESNext 193 | }; 194 | if (moduleId.endsWith(".tsx") || moduleId.endsWith(".jsx")) { 195 | opts.jsx = ts.JsxEmit.React; 196 | } 197 | let body = ts.transpile(transformed, opts); 198 | if (transformer.shouldMergeExport) { 199 | body = ` 200 | function __export(m,k) { 201 | if (k) { 202 | for (const p in k) if (!tsb.exports.hasOwnProperty(k[p])) tsb.exports[k[p]] = m[p]; 203 | } else { 204 | for (const p in m) if (!tsb.exports.hasOwnProperty(p)) tsb.exports[p] = m[p]; 205 | } 206 | } 207 | ${body} 208 | `; 209 | } 210 | modules.push(`"${moduleId}": function (tsb) { ${body} } `); 211 | } 212 | const body = modules.join(","); 213 | const entryId = await resolveModuleId({ 214 | dependency: canonicalName, 215 | moduleId: "." 216 | }); 217 | template = `(${template}).call(this, {${body}}, "${entryId}")`; 218 | const output = ts.transpile(template, { 219 | target: ts.ScriptTarget.ESNext 220 | }); 221 | console.log(output); 222 | } 223 | exports.bundle = bundle; 224 | -------------------------------------------------------------------------------- /dist/compiler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const ts = require("typescript"); 4 | const path = require("path"); 5 | const bundle_1 = require("./bundle"); 6 | function createCompilerHost(options, moduleSearchLocations, urlResolver) { 7 | return { 8 | getSourceFile, 9 | getDefaultLibFileName: () => "lib.d.ts", 10 | writeFile: (fileName, content) => ts.sys.writeFile(fileName, content), 11 | getCurrentDirectory: () => ts.sys.getCurrentDirectory(), 12 | getDirectories: path => ts.sys.getDirectories(path), 13 | getCanonicalFileName: fileName => ts.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), 14 | getNewLine: () => ts.sys.newLine, 15 | useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, 16 | fileExists, 17 | readFile, 18 | resolveModuleNames 19 | }; 20 | function fileExists(fileName) { 21 | return ts.sys.fileExists(fileName); 22 | } 23 | function readFile(fileName) { 24 | return ts.sys.readFile(fileName); 25 | } 26 | function getSourceFile(fileName, languageVersion, onError) { 27 | const sourceText = ts.sys.readFile(fileName); 28 | return sourceText !== undefined 29 | ? ts.createSourceFile(fileName, sourceText, languageVersion) 30 | : undefined; 31 | } 32 | function resolveModuleNames(moduleNames, containingFile) { 33 | const resolvedModules = []; 34 | for (const moduleName of moduleNames) { 35 | if (moduleName.match(bundle_1.kUriRegex)) { 36 | resolvedModules.push({ resolvedFileName: urlResolver(moduleName) }); 37 | } 38 | else { 39 | // try to use standard resolution 40 | let result = ts.resolveModuleName(moduleName, containingFile, options, { 41 | fileExists, 42 | readFile 43 | }); 44 | if (result.resolvedModule) { 45 | resolvedModules.push(result.resolvedModule); 46 | } 47 | else { 48 | // check fallback locations, for simplicity assume that module at location 49 | // should be represented by '.d.ts' file 50 | for (const location of moduleSearchLocations) { 51 | const modulePath = path.join(location, moduleName + ".d.ts"); 52 | if (fileExists(modulePath)) { 53 | resolvedModules.push({ resolvedFileName: modulePath }); 54 | } 55 | } 56 | } 57 | } 58 | } 59 | return resolvedModules; 60 | } 61 | } 62 | exports.createCompilerHost = createCompilerHost; 63 | function compile(sourceFiles, moduleSearchLocations, urlResolver) { 64 | const options = { 65 | module: ts.ModuleKind.AMD, 66 | target: ts.ScriptTarget.ES5 67 | }; 68 | const host = createCompilerHost(options, moduleSearchLocations, urlResolver); 69 | const program = ts.createProgram(sourceFiles, options, host); 70 | /// do something with program... 71 | } 72 | -------------------------------------------------------------------------------- /dist/fetch.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | const node_fetch_1 = require("node-fetch"); 4 | const url_1 = require("url"); 5 | const cachdir = require("cachedir"); 6 | const path = require("path"); 7 | const fs = require("fs-extra"); 8 | const colors_1 = require("colors"); 9 | const crypto = require("crypto"); 10 | const cacheDirectory = cachdir("tsb"); 11 | function urlToCacheFilePath(url) { 12 | const u = new url_1.URL(url); 13 | if (!u.protocol.match(/^https?/)) { 14 | throw new Error("url must start with https?:" + url); 15 | } 16 | const fullPath = u.pathname + u.search; 17 | const scheme = u.protocol.startsWith("https") ? "https" : "http"; 18 | const sha256 = crypto.createHash("sha256"); 19 | sha256.update(fullPath); 20 | const fullPathHash = sha256.digest("hex"); 21 | // ~/Library/Caches/tsb/https/deno.land/{sha256hashOfUrl} 22 | return path.join(cacheDirectory, scheme, u.host, fullPathHash); 23 | } 24 | exports.urlToCacheFilePath = urlToCacheFilePath; 25 | function urlToCacheMetaFilePath(url) { 26 | // ~/Library/Caches/tsb/https/deno.land/{sha256hashOfUrl}.meta.json 27 | return urlToCacheFilePath(url) + ".meta.json"; 28 | } 29 | exports.urlToCacheMetaFilePath = urlToCacheMetaFilePath; 30 | async function saveMetaFile(url, meta) { 31 | const dest = urlToCacheMetaFilePath(url); 32 | await fs.writeFile(dest, JSON.stringify(meta)); 33 | } 34 | const kAcceptableMimeTypes = [ 35 | "text/plain", 36 | "application/javascript", 37 | "text/javascript", 38 | "application/typescript", 39 | "text/typescript" 40 | ]; 41 | async function fetchModule(url) { 42 | const u = new url_1.URL(url); 43 | const originalPath = u.pathname + u.search; 44 | const dest = urlToCacheFilePath(url); 45 | console.error(`${colors_1.green("Download")} ${url}`); 46 | const resp = await node_fetch_1.default(url, { 47 | method: "GET", 48 | redirect: "manual" 49 | }); 50 | const dir = path.dirname(dest); 51 | if (!(await fs.pathExists(dir))) { 52 | await fs.ensureDir(dir); 53 | } 54 | if (400 <= resp.status) { 55 | throw new Error(`fetch failed with status code ${resp.status}`); 56 | } 57 | if (200 <= resp.status && resp.status < 300) { 58 | const contentType = resp.headers.get("content-type") || ""; 59 | if (!kAcceptableMimeTypes.some(v => contentType.startsWith(v))) { 60 | throw new Error(`unacceptable content-type for ${url}: ${contentType} `); 61 | } 62 | await Promise.all([ 63 | // TODO: pipe body stream 64 | fs.writeFile(dest, await resp.text()), 65 | saveMetaFile(url, { mimeType: contentType, originalPath }) 66 | ]); 67 | } 68 | else if (300 <= resp.status) { 69 | const redirectTo = resp.headers.get("location"); 70 | if (!redirectTo) { 71 | throw new Error("redirected response didn't has Location headers!"); 72 | } 73 | await saveMetaFile(url, { 74 | redirectTo, 75 | originalPath 76 | }); 77 | return fetchModule(redirectTo); 78 | } 79 | } 80 | exports.fetchModule = fetchModule; 81 | -------------------------------------------------------------------------------- /dist/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | "use strict"; 3 | Object.defineProperty(exports, "__esModule", { value: true }); 4 | // Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license. 5 | const caporal = require("caporal"); 6 | const bundle_1 = require("./bundle"); 7 | caporal 8 | .name("tsb") 9 | .version("0.7.1") 10 | .argument("file", "entry file path for bundle") 11 | .option("--skipFetch", "skip fetching remote module recursively") 12 | .action(action); 13 | async function action(args, opts) { 14 | try { 15 | await bundle_1.bundle(args.file, opts); 16 | } 17 | catch (e) { 18 | if (e instanceof Error) { 19 | console.error(e.stack); 20 | } 21 | } 22 | } 23 | if (require.main) { 24 | caporal.parse(process.argv); 25 | } 26 | -------------------------------------------------------------------------------- /dist/transform.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | // Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license. 4 | const ts = require("typescript"); 5 | function createTsbImportAccess() { 6 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "import"); 7 | } 8 | function createTsbImportDynamicAccess() { 9 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "importDynamic"); 10 | } 11 | function createTsbExportAccess() { 12 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "exports"); 13 | } 14 | function createTsbResolveCall(module, dep) { 15 | return ts.createCall(ts.createPropertyAccess(ts.createIdentifier("tsb"), "resolveModule"), undefined, [ts.createStringLiteral(module), dep]); 16 | } 17 | class Transformer { 18 | constructor(moduleId, moduleResolver) { 19 | this.moduleId = moduleId; 20 | this.moduleResolver = moduleResolver; 21 | this.shouldMergeExport = false; 22 | this.hasDynamicImport = false; 23 | } 24 | transformers() { 25 | const swapImport = (context) => (rootNode) => { 26 | const visit = (node) => { 27 | node = ts.visitEachChild(node, visit, context); 28 | if (ts.isImportDeclaration(node)) { 29 | return this.transformImport(node); 30 | } 31 | else if (ts.isExportDeclaration(node)) { 32 | return this.transformExportDeclaration(node); 33 | } 34 | else if (ts.isExportAssignment(node)) { 35 | return this.transformExportAssignment(node); 36 | } 37 | else if (ts.isFunctionDeclaration(node)) { 38 | return this.transformExportFunctionDeclaration(node); 39 | } 40 | else if (ts.isVariableStatement(node)) { 41 | return this.transformExportVariableStatement(node); 42 | } 43 | else if (ts.isClassDeclaration(node)) { 44 | return this.transformExportClassDeclaration(node); 45 | } 46 | else if (ts.isEnumDeclaration(node)) { 47 | return this.transformExportEnumDeclaration(node); 48 | } 49 | else if (ts.isCallExpression(node)) { 50 | return this.transformDynamicImport(node); 51 | } 52 | else if (ts.isMetaProperty(node)) { 53 | return this.transformImportMeta(node); 54 | } 55 | return node; 56 | }; 57 | return ts.visitNode(rootNode, visit); 58 | }; 59 | return [swapImport]; 60 | } 61 | normalizeModuleSpecifier(m) { 62 | return this.moduleResolver(this.moduleId, m); 63 | } 64 | transformImport(node) { 65 | const importDecl = node; 66 | const module = this.normalizeModuleSpecifier(importDecl.moduleSpecifier.text); 67 | const importClause = importDecl.importClause; 68 | if (!importClause) { 69 | // import "aaa" 70 | // -> tsb.import("aaa") 71 | return ts.createCall(createTsbImportAccess(), undefined, [ 72 | ts.createStringLiteral(module) 73 | ]); 74 | } 75 | const importName = importClause.name; 76 | const bindings = importClause.namedBindings; 77 | const args = ts.createStringLiteral(module); 78 | const importCall = ts.createCall(createTsbImportAccess(), undefined, [ 79 | args 80 | ]); 81 | const ret = []; 82 | if (importName) { 83 | // import a from "aa" 84 | // -> const a = __tsbImport("aa").default 85 | ret.push(ts.createVariableStatement(undefined, [ 86 | ts.createVariableDeclaration(importName, undefined, ts.createPropertyAccess(importCall, "default")) 87 | ])); 88 | } 89 | if (bindings) { 90 | if (ts.isNamedImports(bindings)) { 91 | // import { a, b } from "aa" 92 | // -> const {a, b} = tsb.import("typescript"); 93 | const elements = bindings.elements.map(v => { 94 | if (v.propertyName) { 95 | return ts.createBindingElement(undefined, v.propertyName, v.name); 96 | } 97 | else { 98 | return ts.createBindingElement(undefined, undefined, v.name); 99 | } 100 | }); 101 | ret.push(ts.createVariableStatement(undefined, [ 102 | ts.createVariableDeclaration(ts.createObjectBindingPattern(elements), undefined, importCall) 103 | ])); 104 | } 105 | else if (ts.isNamespaceImport(bindings)) { 106 | // import * as ts from "typescript" 107 | // -> const ts = tsb.import("typescript"); 108 | ret.push(ts.createVariableStatement(undefined, [ 109 | ts.createVariableDeclaration(bindings.name, undefined, importCall) 110 | ])); 111 | } 112 | } 113 | return ret; 114 | } 115 | transformDynamicImport(node) { 116 | if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { 117 | this.hasDynamicImport = true; 118 | const [module] = node.arguments; 119 | let moduleSpecifier; 120 | if (ts.isStringLiteral(module)) { 121 | moduleSpecifier = ts.createStringLiteral(this.normalizeModuleSpecifier(module.text)); 122 | } 123 | else { 124 | moduleSpecifier = createTsbResolveCall(this.moduleId, module); 125 | } 126 | return ts.createCall(createTsbImportDynamicAccess(), node.typeArguments, [ 127 | moduleSpecifier 128 | ]); 129 | } 130 | return node; 131 | } 132 | transformExportDeclaration(node) { 133 | this.shouldMergeExport = true; 134 | const exportClause = node.exportClause; 135 | const module = node.moduleSpecifier; 136 | if (exportClause) { 137 | if (module) { 138 | const keyMap = exportClause.elements.map(v => { 139 | let propertyName = v.name.text; 140 | if (v.propertyName) { 141 | propertyName = v.propertyName.text; 142 | } 143 | return ts.createPropertyAssignment(ts.createStringLiteral(propertyName), ts.createStringLiteral(v.name.text)); 144 | }); 145 | // export {a, b as B} from "..." 146 | // => __export(require("..."), {"a": "a", "b": "B"}) 147 | const text = module.text; 148 | return ts.createCall(ts.createIdentifier("__export"), undefined, [ 149 | ts.createCall(createTsbImportAccess(), undefined, [ 150 | ts.createStringLiteral(this.normalizeModuleSpecifier(text)) 151 | ]), 152 | ts.createObjectLiteral(keyMap) 153 | ]); 154 | } 155 | else { 156 | const assignments = exportClause.elements.map(v => { 157 | let propertyName = v.name.text; 158 | if (v.propertyName) { 159 | propertyName = v.propertyName.text; 160 | return ts.createPropertyAssignment(propertyName, ts.createIdentifier(v.name.text)); 161 | } 162 | else { 163 | return ts.createShorthandPropertyAssignment(propertyName); 164 | } 165 | }); 166 | // export { a, b as B} 167 | // => __export({a, B: b}) 168 | return ts.createCall(ts.createIdentifier("__export"), undefined, [ 169 | ts.createObjectLiteral(assignments) 170 | ]); 171 | } 172 | } 173 | else { 174 | const text = module.text; 175 | return ts.createCall(ts.createIdentifier("__export"), undefined, [ 176 | ts.createCall(createTsbImportAccess(), undefined, [ 177 | ts.createStringLiteral(this.normalizeModuleSpecifier(text)) 178 | ]) 179 | ]); 180 | } 181 | } 182 | transformExportAssignment(node) { 183 | if (node.isExportEquals) { 184 | // export = {} 185 | // -> tsb.exports = {} 186 | return ts.createAssignment(createTsbExportAccess(), node.expression); 187 | } 188 | else { 189 | // export default {} 190 | // -> tsb.exports.default = {} 191 | const name = node.name ? node.name.text : "default"; 192 | return ts.createAssignment(ts.createPropertyAccess(createTsbExportAccess(), name), node.expression); 193 | } 194 | } 195 | transformExportFunctionDeclaration(node) { 196 | if (node.modifiers && 197 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword) { 198 | if (node.modifiers[1] && 199 | node.modifiers[1].kind === ts.SyntaxKind.DefaultKeyword) { 200 | // export default function a() {} 201 | // -> tsb.exports.default = function a() {} 202 | const [_, __, ...rest] = node.modifiers; 203 | return ts.createAssignment(ts.createPropertyAccess(createTsbExportAccess(), "default"), ts.createFunctionExpression([...rest], node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, node.body)); 204 | } 205 | else { 206 | // export function a() {} 207 | // -> 208 | // function a() {} 209 | // tsb.exports.a = a; 210 | const [_, ...rest] = node.modifiers; 211 | return [ 212 | ts.createFunctionExpression([...rest], node.asteriskToken, node.name, node.typeParameters, node.parameters, node.type, node.body), 213 | ts.createAssignment(ts.createPropertyAccess(createTsbExportAccess(), node.name), node.name) 214 | ]; 215 | } 216 | } 217 | return node; 218 | } 219 | transformExportVariableStatement(node) { 220 | if (node.modifiers && 221 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword) { 222 | // export const a = {} 223 | // -> 224 | // const a = {} 225 | // export.a = a; 226 | const [_, ...restModifiers] = node.modifiers; 227 | const declarations = ts.createVariableStatement(restModifiers, node.declarationList); 228 | const exprs = node.declarationList.declarations.map(v => { 229 | return ts.createAssignment(ts.createPropertyAccess(createTsbExportAccess(), v.name.text), v.name); 230 | }); 231 | return [declarations, ...exprs]; 232 | } 233 | return node; 234 | } 235 | transformExportClassDeclaration(node) { 236 | if (node.modifiers && 237 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword) { 238 | let left; 239 | if (node.modifiers[1] && 240 | node.modifiers[1].kind === ts.SyntaxKind.DefaultKeyword) { 241 | // export default class Class {} 242 | // -> tsb.exports.default = Class 243 | left = ts.createPropertyAccess(createTsbExportAccess(), "default"); 244 | } 245 | else { 246 | // export class Class{} 247 | // -> tsb.exports.Class = Class; 248 | left = ts.createPropertyAccess(createTsbExportAccess(), node.name); 249 | } 250 | return ts.createAssignment(left, ts.createClassExpression(undefined, node.name, node.typeParameters, node.heritageClauses, node.members)); 251 | } 252 | return node; 253 | } 254 | transformExportEnumDeclaration(node) { 255 | if (node.modifiers && 256 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword) { 257 | // export enum Enum {} 258 | // -> enum Enum {}, tsb.exports.Enum = Enum 259 | const [_, ...rest] = node.modifiers; 260 | return [ 261 | ts.createEnumDeclaration(node.decorators, [...rest], node.name, node.members), 262 | ts.createAssignment(ts.createPropertyAccess(createTsbExportAccess(), node.name), node.name) 263 | ]; 264 | } 265 | return [node]; 266 | } 267 | transformImportMeta(node) { 268 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "meta"); 269 | } 270 | } 271 | exports.Transformer = Transformer; 272 | -------------------------------------------------------------------------------- /example/dynamic_import.ts: -------------------------------------------------------------------------------- 1 | async function func() { 2 | import("./other.ts").then(async other => { 3 | console.log(other.callNever()); 4 | const color = await import("https://deno.land/std@v0.15.0/colors/mod.ts"); 5 | console.log(color); 6 | }); 7 | // const a = "./other.ts"; 8 | const b = "https://deno.land/std@v0.15.0/colors/mod.ts"; 9 | // console.log(await import(a)); 10 | console.log(await import(b)); 11 | } 12 | func(); 13 | -------------------------------------------------------------------------------- /example/equal.ts: -------------------------------------------------------------------------------- 1 | export = { equal: 1 }; 2 | -------------------------------------------------------------------------------- /example/example.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "https://deno.land/std@v0.15.0/http/server.ts"; 2 | import { cyan } from "https://deno.land/std@v0.15.0/colors/mod.ts"; 3 | import some from "./some.ts"; 4 | import { callOther, callNever as callAnother, callNever } from "./other.ts"; 5 | import * as hoge from "./hoge.ts"; 6 | import "./dynamic_import.ts"; 7 | import other, * as otherAll from "./other.ts"; 8 | import other2, { callNever as callNever2 } from "./other.ts"; 9 | 10 | export default { a: 1 }; 11 | export const f = 2; 12 | export function func() {} 13 | export * from "./some.ts"; 14 | export { a, b, c as CC } from "./hoge.ts"; 15 | export class SomeClass {} 16 | export enum Fuga { 17 | a = 1 18 | } 19 | enum Hoge { 20 | h = 1, 21 | v = 2 22 | } 23 | export { Hoge }; 24 | export let variable, variable2; 25 | 26 | console.log(cyan(callOther() + ":" + callNever())); 27 | -------------------------------------------------------------------------------- /example/hoge.ts: -------------------------------------------------------------------------------- 1 | export const a = 1; 2 | export const b = 2; 3 | export const c = 3; 4 | -------------------------------------------------------------------------------- /example/import_meta.ts: -------------------------------------------------------------------------------- 1 | console.log(import.meta.url); -------------------------------------------------------------------------------- /example/nodelib.ts: -------------------------------------------------------------------------------- 1 | import Debug from "https://dev.jspm.io/debug"; 2 | import colors from "https://dev.jspm.io/colors"; 3 | 4 | const debug = Debug("tsb"); 5 | debug("TypeScript"); 6 | console.log(colors.green("green")); 7 | -------------------------------------------------------------------------------- /example/other.ts: -------------------------------------------------------------------------------- 1 | export function callOther(): string { 2 | return "other"; 3 | } 4 | 5 | export function callNever(): string { 6 | return "never"; 7 | } 8 | 9 | export default function() { 10 | return "default"; 11 | } 12 | -------------------------------------------------------------------------------- /example/react.tsx: -------------------------------------------------------------------------------- 1 | import React from "https://dev.jspm.io/react" 2 | import ReactDOM from "https://dev.jspm.io/react-dom" 3 | 4 | const View = () => { 5 | const [now, setNow] = React.useState(new Date()); 6 | return ( 7 |
8 | now: {now.toISOString()} 9 | 10 |
11 | ) 12 | }; 13 | 14 | window.addEventListener("DOMContentLoaded", () => { 15 | ReactDOM.render(, document.body); 16 | }); 17 | -------------------------------------------------------------------------------- /example/server.ts: -------------------------------------------------------------------------------- 1 | import { listenAndServe } from "https://denopkg.com/keroxp/servest@v0.9.0/server.ts"; 2 | listenAndServe(":8899", async req => { 3 | await req.respond({ 4 | status: 200, 5 | headers: new Headers({ 6 | "Content-Type": "text/plain" 7 | }), 8 | body: new TextEncoder().encode("hello") 9 | }); 10 | }); 11 | console.log("server is running on :8899..."); 12 | -------------------------------------------------------------------------------- /example/some.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | prop: 1 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@keroxp/tsb", 3 | "version": "0.8.1", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "bin": "./dist/main.js", 8 | "description": "TypeScript module bundler for Deno", 9 | "main": "./dist/main.js", 10 | "repository": "https://github.com/keroxp/tsb", 11 | "author": "keroxp ", 12 | "license": "MIT", 13 | "scripts": { 14 | "b": "tsc", 15 | "fmt": "prettier --write '**/*.ts'", 16 | "test": "jest --forceExit" 17 | }, 18 | "jest": { 19 | "preset": "ts-jest" 20 | }, 21 | "dependencies": { 22 | "cachedir": "^2.2.0", 23 | "caporal": "^1.3.0", 24 | "colors": "^1.3.3", 25 | "node-fetch": "^2.6.0", 26 | "typescript": "^3.5.3", 27 | "fs-extra": "^8.1.0" 28 | }, 29 | "devDependencies": { 30 | "@types/node-fetch": "^2.5.0", 31 | "@types/fs-extra": "^8.0.0", 32 | "@types/jest": "^24.0.17", 33 | "@types/node": "^12.7.1", 34 | "jest": "^24.8.0", 35 | "prettier": "^1.18.2", 36 | "ts-jest": "^24.0.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/__tests__/bundle.test.ts: -------------------------------------------------------------------------------- 1 | import { joinModuleId } from "../bundle"; 2 | import * as ts from "typescript"; 3 | describe("bundle", () => { 4 | describe("normalizeModule", () => { 5 | test("any + url", () => { 6 | const res = joinModuleId({ 7 | moduleId: "https://deno.land/hoge.ts", 8 | dependency: "https://deno.land/some.ts" 9 | }); 10 | expect(res).toBe("https://deno.land/some.ts"); 11 | }); 12 | test("url + relative", () => { 13 | const res = joinModuleId({ 14 | moduleId: "https://deno.land/hoge.ts", 15 | dependency: "./some.ts" 16 | }); 17 | expect(res).toBe("https://deno.land/some.ts"); 18 | }); 19 | test("relative + relative", () => { 20 | const res = joinModuleId({ 21 | moduleId: "./example/hoge.ts", 22 | dependency: "./some.ts" 23 | }); 24 | expect(res).toBe("./example/some.ts"); 25 | }); 26 | test("relative + relative (subdir)", () => { 27 | const res = joinModuleId({ 28 | moduleId: "./example/subdir/hoge.ts", 29 | dependency: "../otherdir/some.ts" 30 | }); 31 | expect(res).toBe("./example/otherdir/some.ts"); 32 | }); 33 | test("root", () => { 34 | const res = joinModuleId({ 35 | moduleId: ".", 36 | dependency: "./example/some.ts" 37 | }); 38 | expect(res).toBe("./example/some.ts"); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/__tests__/fetch.test.ts: -------------------------------------------------------------------------------- 1 | import { urlToCacheFilePath, urlToCacheMetaFilePath } from "../fetch"; 2 | import * as cachedir from "cachedir"; 3 | import * as path from "path"; 4 | describe("fetch", () => { 5 | test("urlToCachePath", () => { 6 | const url = "https://deno.land/sub/dir/script.ts?query=1"; 7 | const res = urlToCacheFilePath(url); 8 | const exp = path.resolve( 9 | cachedir("tsb"), 10 | "https", 11 | "deno.land", 12 | // /sub/dir/script.ts?query=1 13 | "118a6a93e2c6de545787b444e91ef3906f20688e4b47a55cb28f14a05e51dcab" 14 | ); 15 | expect(res).toBe(exp); 16 | expect(urlToCacheMetaFilePath(url)).toBe(exp + ".meta.json"); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/__tests__/transform.test.ts: -------------------------------------------------------------------------------- 1 | import { Transformer } from "../transform"; 2 | import * as ts from "typescript"; 3 | 4 | describe("transform", () => { 5 | const printer = ts.createPrinter(); 6 | const t = new Transformer("./example.ts", (moduleId, dep) => { 7 | return dep; 8 | }); 9 | 10 | function transform(text: string): string { 11 | const src = ts.createSourceFile("example.ts", text, ts.ScriptTarget.ESNext); 12 | const result = ts.transform(src, t.transformers()); 13 | return printer.printFile(result.transformed[0] as ts.SourceFile); 14 | } 15 | 16 | describe("import", () => { 17 | test("namedImport(relative)", () => { 18 | const res = transform(`import { a } from "./some.ts";\n`); 19 | expect(res).toBe(`var { a } = tsb.import("./some.ts");\n`); 20 | }); 21 | test("namedImport(url)", () => { 22 | const res = transform( 23 | `import { serve } from "https://deno.land/std/http/server.ts";\n` 24 | ); 25 | expect(res).toBe( 26 | `var { serve } = tsb.import("https://deno.land/std/http/server.ts");\n` 27 | ); 28 | }); 29 | test("binding", () => { 30 | const res = transform( 31 | `import { serve as doServe } from "https://deno.land/std/http/server.ts";\n` 32 | ); 33 | expect(res).toBe( 34 | `var { serve: doServe } = tsb.import("https://deno.land/std/http/server.ts");\n` 35 | ); 36 | }); 37 | test("*", () => { 38 | const res = transform( 39 | `import * as http from "https://deno.land/std/http/server.ts";\n` 40 | ); 41 | expect(res).toBe( 42 | `var http = tsb.import("https://deno.land/std/http/server.ts");\n` 43 | ); 44 | }); 45 | test("default", () => { 46 | const res = transform( 47 | `import http from "https://deno.land/std/http/server.ts";\n` 48 | ); 49 | expect(res).toBe( 50 | `var http = tsb.import("https://deno.land/std/http/server.ts").default;\n` 51 | ); 52 | }); 53 | test("default + namedImport", () => { 54 | const res = transform( 55 | `import http, { some, other as doOther } from "https://deno.land/std/http/server.ts";\n` 56 | ); 57 | // prettier-ignore 58 | expect(res).toBe( 59 | `var http = tsb.import("https://deno.land/std/http/server.ts").default; 60 | var { some, other: doOther } = tsb.import("https://deno.land/std/http/server.ts"); 61 | ` 62 | ); 63 | }); 64 | test("default + namespace", () => { 65 | const res = transform( 66 | `import http, * as all from "https://deno.land/std/http/server.ts";\n` 67 | ); 68 | // prettier-ignore 69 | expect(res).toBe( 70 | `var http = tsb.import("https://deno.land/std/http/server.ts").default; 71 | var all = tsb.import("https://deno.land/std/http/server.ts"); 72 | ` 73 | ); 74 | }); 75 | test("dynamic", () => { 76 | const res = transform(`import("hoge").then(v => { })`); 77 | expect(res).toBe(`tsb.importDynamic("hoge").then(v => { });\n`); 78 | }); 79 | test("unassigned", () => { 80 | const res = transform(`import "aa"`); 81 | expect(res).toBe(`tsb.import("aa")\n`); 82 | }); 83 | }); 84 | describe("import.meta", () => { 85 | [ 86 | ["import.meta.url", "tsb.meta.url;\n"], 87 | ["import.meta.isMain", "tsb.meta.isMain;\n"], 88 | ["import.meta", "tsb.meta;\n"] 89 | ].forEach(([before, after]) => { 90 | expect(transform(before)).toBe(after); 91 | }); 92 | }); 93 | describe("export", () => { 94 | test("named", () => { 95 | const res = transform(`export { Hoge }`); 96 | expect(res).toBe("__export({ Hoge })\n"); 97 | }); 98 | test("default", () => { 99 | const res = transform("export default 1"); 100 | expect(res).toBe("tsb.exports.default = 1\n"); 101 | }); 102 | test("default function(anonymous)", () => { 103 | const res = transform("export default function () { }"); 104 | expect(res).toBe("tsb.exports.default = function () { }\n"); 105 | }); 106 | test("default function(named)", () => { 107 | const res = transform("export default function func() { }"); 108 | expect(res).toBe("tsb.exports.default = function func() { }\n"); 109 | }); 110 | test("default class", () => { 111 | const res = transform("export default class Class {};\n"); 112 | expect(res).toBe("tsb.exports.default = class Class {\n}\n;\n"); 113 | }); 114 | test("variable", () => { 115 | const res = transform("export const kon = 1;\n"); 116 | expect(res).toBe("const kon = 1;\ntsb.exports.kon = kon\n"); 117 | }); 118 | test("function", () => { 119 | const res = transform("export function func() {};\n"); 120 | expect(res).toBe("function func() { }\ntsb.exports.func = func\n;\n"); 121 | }); 122 | test("class", () => { 123 | const res = transform("export class Class {};\n"); 124 | expect(res).toBe("tsb.exports.Class = class Class {\n}\n;\n"); 125 | }); 126 | test("enum", () => { 127 | const res = transform("export enum Enum {};\n"); 128 | expect(res).toBe("enum Enum {\n}\ntsb.exports.Enum = Enum\n;\n"); 129 | }); 130 | test("assignment", () => { 131 | const res = transform(`export * from "./other.ts"`); 132 | expect(res).toBe(`__export(tsb.import("./other.ts"))\n`); 133 | }); 134 | test("named with module specifier", () => { 135 | const res = transform(`export { Hoge, Fuga as fuga } from "hoge"`); 136 | expect(res).toBe( 137 | `__export(tsb.import("hoge"), { "Hoge": "Hoge", "Fuga": "fuga" })\n` 138 | ); 139 | }); 140 | test("default with module specifier", () => { 141 | const res = transform(`export { default } from "hoge"`); 142 | expect(res).toBe( 143 | `__export(tsb.import("hoge"), { "default": "default" })\n` 144 | ); 145 | }); 146 | test("equal", () => { 147 | const res = transform("export = {}"); 148 | expect(res).toBe("tsb.exports = {}\n"); 149 | }); 150 | }); 151 | 152 | test("a", () => { 153 | const v = (n:ts.Node) => { 154 | console.log(ts.SyntaxKind[n.kind],n) 155 | n.forEachChild(v) 156 | }; 157 | ts.forEachChild( 158 | ts.createSourceFile("", "import.meta", ts.ScriptTarget.ESNext), 159 | v, 160 | ) 161 | }) 162 | }); 163 | -------------------------------------------------------------------------------- /src/bundle.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license. 2 | import * as path from "path"; 3 | import * as url from "url"; 4 | import { Transformer } from "./transform"; 5 | import * as fs from "fs-extra"; 6 | import * as ts from "typescript"; 7 | import { 8 | CacheFileMetadata, 9 | fetchModule, 10 | urlToCacheFilePath, 11 | urlToCacheMetaFilePath 12 | } from "./fetch"; 13 | import { CliOptions } from "./main"; 14 | 15 | export const kUriRegex = /^(https?):\/\/(.+?)$/; 16 | export const kRelativeRegex = /^\.\.?\/.+?\.[jt]sx?$/; 17 | 18 | async function readFileAsync(file: string): Promise { 19 | return String(await fs.readFile(file)); 20 | } 21 | 22 | export type SourceFile = { 23 | moduleId: string; 24 | dependency: string; 25 | }; 26 | 27 | async function fileExists(p: string): Promise { 28 | return fs.pathExists(p); 29 | } 30 | 31 | async function resolveUri(id: string): Promise { 32 | if (id.match(kUriRegex)) { 33 | return urlToCacheFilePath(id); 34 | } else if (id.match(kRelativeRegex)) { 35 | return path.resolve(id); 36 | } else { 37 | throw new Error("invalid module specifier: " + id); 38 | } 39 | } 40 | 41 | export async function resolveModuleId( 42 | source: SourceFile, 43 | skipFetch = false 44 | ): Promise { 45 | if (source.dependency.match(kUriRegex)) { 46 | // any + url 47 | const cachePath = urlToCacheFilePath(source.dependency); 48 | const cacheMetaPath = urlToCacheMetaFilePath(source.dependency); 49 | if (!(await fileExists(cachePath))) { 50 | if (!(await fileExists(cacheMetaPath))) { 51 | if (!skipFetch) { 52 | await fetchModule(source.dependency); 53 | return resolveModuleId(source, false); 54 | } else { 55 | throw createError( 56 | source, 57 | ` 58 | Cache file was not found in: ${cachePath}. 59 | ` 60 | ); 61 | } 62 | } 63 | const headers = await readFileAsync(cacheMetaPath); 64 | const meta = JSON.parse(headers) as CacheFileMetadata; 65 | if (!meta.redirectTo) { 66 | throw new Error(`meta file for ${source.dependency} may be broken`); 67 | } 68 | return resolveModuleId({ 69 | moduleId: ".", 70 | dependency: meta.redirectTo 71 | }); 72 | } else { 73 | return source.dependency; 74 | } 75 | } else if (source.moduleId.match(kUriRegex)) { 76 | // url + relative 77 | return resolveModuleId({ 78 | moduleId: ".", 79 | dependency: url.resolve(source.moduleId, source.dependency) 80 | }); 81 | } else { 82 | // relative + relative 83 | return joinModuleId(source); 84 | } 85 | } 86 | 87 | async function traverseDependencyTree( 88 | sourceFile: SourceFile, 89 | dependencyTree: Map, 90 | redirectionMap: Map, 91 | opts: CliOptions 92 | ): Promise { 93 | const dependencies: string[] = []; 94 | let id: string; 95 | id = await resolveModuleId(sourceFile, opts.skipFetch); 96 | redirectionMap.set(joinModuleId(sourceFile), id); 97 | if (dependencyTree.has(id)) { 98 | return; 99 | } 100 | dependencyTree.set(id, sourceFile); 101 | 102 | const visit = (node: ts.Node) => { 103 | if (ts.isImportDeclaration(node)) { 104 | const dependency = (node.moduleSpecifier as ts.StringLiteral).text; 105 | dependencies.push(dependency); 106 | } else if ( 107 | ts.isCallExpression(node) && 108 | node.expression.kind === ts.SyntaxKind.ImportKeyword 109 | ) { 110 | // import("aa").then(v => {}) 111 | const [module] = node.arguments; 112 | if (ts.isStringLiteral(module)) { 113 | dependencies.push(module.text); 114 | } 115 | } else if (ts.isExportDeclaration(node)) { 116 | const exportClause = node.exportClause; 117 | const module = node.moduleSpecifier; 118 | if (exportClause) { 119 | if (module) { 120 | // export {a,b} form "bb" 121 | dependencies.push((module as ts.StringLiteral).text); 122 | } else { 123 | // export {a,b 124 | } 125 | } else { 126 | dependencies.push((module as ts.StringLiteral).text); 127 | } 128 | } 129 | ts.forEachChild(node, visit); 130 | }; 131 | 132 | const resolvedPath = await resolveUri(id); 133 | const text = await readFileAsync(resolvedPath); 134 | const src = ts.createSourceFile(resolvedPath, text, ts.ScriptTarget.ESNext); 135 | ts.forEachChild(src, visit); 136 | for (const dependency of dependencies) { 137 | await traverseDependencyTree( 138 | { dependency: dependency, moduleId: id }, 139 | dependencyTree, 140 | redirectionMap, 141 | opts 142 | ); 143 | } 144 | } 145 | 146 | export function joinModuleId(source: SourceFile): string { 147 | if (source.dependency.match(kUriRegex)) { 148 | // url 149 | return source.dependency; 150 | } else if (source.moduleId.match(kUriRegex)) { 151 | // url + relative 152 | return url.resolve(source.moduleId, source.dependency); 153 | } else if (source.dependency.match(kRelativeRegex)) { 154 | // relative + relative 155 | const cwd = process.cwd(); 156 | const dir = path.dirname(source.moduleId); 157 | return "./" + path.relative(cwd, path.join(dir, source.dependency)); 158 | } else { 159 | throw createError(source, `dependency must be URL or start with ./ or ../`); 160 | } 161 | } 162 | 163 | function createError(source: SourceFile, message: string): Error { 164 | return new Error( 165 | `moduleId: "${source.moduleId}", dependency: "${source.dependency}": ${message}` 166 | ); 167 | } 168 | 169 | export async function bundle(entry: string, opts: CliOptions) { 170 | const tree = new Map(); 171 | const redirectionMap = new Map(); 172 | let canonicalName: string; 173 | if (entry.match(kUriRegex)) { 174 | canonicalName = entry; 175 | } else { 176 | canonicalName = "./" + path.relative(process.cwd(), entry); 177 | } 178 | await traverseDependencyTree( 179 | { 180 | dependency: canonicalName, 181 | moduleId: "." 182 | }, 183 | tree, 184 | redirectionMap, 185 | opts 186 | ); 187 | const printer = ts.createPrinter(); 188 | let template = await readFileAsync( 189 | path.resolve(__dirname, "../template/template.ts") 190 | ); 191 | const resolveModule = (moduleId: string, dep: string): string => { 192 | const redirection = redirectionMap.get(moduleId); 193 | if (!redirection) { 194 | throw new Error(`${moduleId} not found in redirection map`); 195 | } 196 | if (dep.match(kUriRegex)) { 197 | const ret = redirectionMap.get(dep); 198 | if (!ret) { 199 | throw new Error(`${dep} not found in redirection map`); 200 | } 201 | return ret; 202 | } else { 203 | return joinModuleId({ 204 | moduleId: moduleId, 205 | dependency: dep 206 | }); 207 | } 208 | }; 209 | const modules: string[] = []; 210 | for (const [moduleId] of tree.entries()) { 211 | const transformer = new Transformer(moduleId, resolveModule); 212 | let text = await readFileAsync(await resolveUri(moduleId)); 213 | if (text.startsWith("#!")) { 214 | // disable shell 215 | text = "//" + text; 216 | } 217 | const src = ts.createSourceFile(moduleId, text, ts.ScriptTarget.ESNext); 218 | const result = ts.transform(src, transformer.transformers()); 219 | const transformed = printer.printFile(result 220 | .transformed[0] as ts.SourceFile); 221 | const opts: ts.CompilerOptions = { 222 | target: ts.ScriptTarget.ESNext 223 | }; 224 | if (moduleId.endsWith(".tsx") || moduleId.endsWith(".jsx")) { 225 | opts.jsx = ts.JsxEmit.React; 226 | } 227 | let body = ts.transpile(transformed, opts); 228 | if (transformer.shouldMergeExport) { 229 | body = ` 230 | function __export(m,k) { 231 | if (k) { 232 | for (const p in k) if (!tsb.exports.hasOwnProperty(k[p])) tsb.exports[k[p]] = m[p]; 233 | } else { 234 | for (const p in m) if (!tsb.exports.hasOwnProperty(p)) tsb.exports[p] = m[p]; 235 | } 236 | } 237 | ${body} 238 | `; 239 | } 240 | modules.push(`"${moduleId}": function (tsb) { ${body} } `); 241 | } 242 | const body = modules.join(","); 243 | const entryId = await resolveModuleId({ 244 | dependency: canonicalName, 245 | moduleId: "." 246 | }); 247 | template = `(${template}).call(this, {${body}}, "${entryId}")`; 248 | const output = ts.transpile(template, { 249 | target: ts.ScriptTarget.ESNext 250 | }); 251 | console.log(output); 252 | } 253 | -------------------------------------------------------------------------------- /src/fetch.ts: -------------------------------------------------------------------------------- 1 | import { default as fetch } from "node-fetch"; 2 | import { URL } from "url"; 3 | import * as cachdir from "cachedir"; 4 | import * as path from "path"; 5 | import * as fs from "fs-extra"; 6 | import { green } from "colors"; 7 | import * as crypto from "crypto"; 8 | 9 | const cacheDirectory = cachdir("tsb"); 10 | 11 | export function urlToCacheFilePath(url: string): string { 12 | const u = new URL(url); 13 | if (!u.protocol.match(/^https?/)) { 14 | throw new Error("url must start with https?:" + url); 15 | } 16 | const fullPath = u.pathname + u.search; 17 | const scheme = u.protocol.startsWith("https") ? "https" : "http"; 18 | const sha256 = crypto.createHash("sha256"); 19 | sha256.update(fullPath); 20 | const fullPathHash = sha256.digest("hex"); 21 | // ~/Library/Caches/tsb/https/deno.land/{sha256hashOfUrl} 22 | return path.join(cacheDirectory, scheme, u.host, fullPathHash); 23 | } 24 | 25 | export function urlToCacheMetaFilePath(url: string): string { 26 | // ~/Library/Caches/tsb/https/deno.land/{sha256hashOfUrl}.meta.json 27 | return urlToCacheFilePath(url) + ".meta.json"; 28 | } 29 | 30 | export type CacheFileMetadata = { 31 | redirectTo?: string; 32 | mimeType?: string; 33 | originalPath: string; 34 | }; 35 | 36 | async function saveMetaFile( 37 | url: string, 38 | meta: CacheFileMetadata 39 | ): Promise { 40 | const dest = urlToCacheMetaFilePath(url); 41 | await fs.writeFile(dest, JSON.stringify(meta)); 42 | } 43 | 44 | const kAcceptableMimeTypes = [ 45 | "text/plain", 46 | "application/javascript", 47 | "text/javascript", 48 | "application/typescript", 49 | "text/typescript" 50 | ]; 51 | 52 | export async function fetchModule(url: string): Promise { 53 | const u = new URL(url); 54 | const originalPath = u.pathname + u.search; 55 | const dest = urlToCacheFilePath(url); 56 | console.error(`${green("Download")} ${url}`); 57 | const resp = await fetch(url, { 58 | method: "GET", 59 | redirect: "manual" 60 | }); 61 | const dir = path.dirname(dest); 62 | if (!(await fs.pathExists(dir))) { 63 | await fs.ensureDir(dir); 64 | } 65 | if (400 <= resp.status) { 66 | throw new Error(`fetch failed with status code ${resp.status}`); 67 | } 68 | if (200 <= resp.status && resp.status < 300) { 69 | const contentType = resp.headers.get("content-type") || ""; 70 | if (!kAcceptableMimeTypes.some(v => contentType.startsWith(v))) { 71 | throw new Error(`unacceptable content-type for ${url}: ${contentType} `); 72 | } 73 | await Promise.all([ 74 | // TODO: pipe body stream 75 | fs.writeFile(dest, await resp.text()), 76 | saveMetaFile(url, { mimeType: contentType, originalPath }) 77 | ]); 78 | } else if (300 <= resp.status) { 79 | const redirectTo = resp.headers.get("location"); 80 | if (!redirectTo) { 81 | throw new Error("redirected response didn't has Location headers!"); 82 | } 83 | await saveMetaFile(url, { 84 | redirectTo, 85 | originalPath 86 | }); 87 | return fetchModule(redirectTo); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license. 3 | import * as caporal from "caporal"; 4 | import { bundle } from "./bundle"; 5 | 6 | caporal 7 | .name("tsb") 8 | .version("0.8.1") 9 | .argument("file", "entry file path for bundle") 10 | .option("--skipFetch", "skip fetching remote module recursively") 11 | .action(action); 12 | 13 | export type CliOptions = { 14 | skipFetch: boolean; 15 | }; 16 | 17 | async function action(args: { file: string }, opts: CliOptions) { 18 | try { 19 | await bundle(args.file, opts); 20 | } catch (e) { 21 | if (e instanceof Error) { 22 | console.error(e.stack); 23 | } 24 | } 25 | } 26 | 27 | if (require.main) { 28 | caporal.parse(process.argv); 29 | } 30 | -------------------------------------------------------------------------------- /src/transform.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license. 2 | import * as ts from "typescript"; 3 | 4 | function createTsbImportAccess(): ts.Expression { 5 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "import"); 6 | } 7 | function createTsbImportDynamicAccess(): ts.Expression { 8 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "importDynamic"); 9 | } 10 | function createTsbExportAccess(): ts.Expression { 11 | return ts.createPropertyAccess(ts.createIdentifier("tsb"), "exports"); 12 | } 13 | function createTsbResolveCall( 14 | module: string, 15 | dep: ts.Expression 16 | ): ts.Expression { 17 | return ts.createCall( 18 | ts.createPropertyAccess(ts.createIdentifier("tsb"), "resolveModule"), 19 | undefined, 20 | [ts.createStringLiteral(module), dep] 21 | ); 22 | } 23 | export class Transformer { 24 | shouldMergeExport: boolean = false; 25 | hasDynamicImport = false; 26 | 27 | constructor( 28 | readonly moduleId: string, 29 | readonly moduleResolver: (moduleId: string, dep: string) => string 30 | ) {} 31 | 32 | transformers() { 33 | const swapImport = ( 34 | context: ts.TransformationContext 35 | ) => (rootNode: T) => { 36 | const visit = (node: ts.Node): ts.VisitResult => { 37 | node = ts.visitEachChild(node, visit, context); 38 | if (ts.isImportDeclaration(node)) { 39 | return this.transformImport(node); 40 | } else if (ts.isExportDeclaration(node)) { 41 | return this.transformExportDeclaration(node); 42 | } else if (ts.isExportAssignment(node)) { 43 | return this.transformExportAssignment(node); 44 | } else if (ts.isFunctionDeclaration(node)) { 45 | return this.transformExportFunctionDeclaration(node); 46 | } else if (ts.isVariableStatement(node)) { 47 | return this.transformExportVariableStatement(node); 48 | } else if (ts.isClassDeclaration(node)) { 49 | return this.transformExportClassDeclaration(node); 50 | } else if (ts.isEnumDeclaration(node)) { 51 | return this.transformExportEnumDeclaration(node); 52 | } else if (ts.isCallExpression(node)) { 53 | return this.transformDynamicImport(node); 54 | } else if (ts.isMetaProperty(node)) { 55 | return this.transformImportMeta(node); 56 | } 57 | return node; 58 | }; 59 | return ts.visitNode(rootNode, visit); 60 | }; 61 | return [swapImport]; 62 | } 63 | 64 | normalizeModuleSpecifier(m: string): string { 65 | return this.moduleResolver(this.moduleId, m); 66 | } 67 | 68 | transformImport(node: ts.ImportDeclaration): ts.VisitResult { 69 | const importDecl: ts.ImportDeclaration = node; 70 | const module = this.normalizeModuleSpecifier( 71 | (importDecl.moduleSpecifier as ts.StringLiteral).text 72 | ); 73 | const importClause = importDecl.importClause; 74 | if (!importClause) { 75 | // import "aaa" 76 | // -> tsb.import("aaa") 77 | return ts.createCall(createTsbImportAccess(), undefined, [ 78 | ts.createStringLiteral(module) 79 | ]); 80 | } 81 | const importName = importClause.name; 82 | const bindings = importClause!.namedBindings; 83 | const args = ts.createStringLiteral(module); 84 | const importCall = ts.createCall(createTsbImportAccess(), undefined, [ 85 | args 86 | ]); 87 | const ret: ts.Node[] = []; 88 | if (importName) { 89 | // import a from "aa" 90 | // -> const a = __tsbImport("aa").default 91 | ret.push( 92 | ts.createVariableStatement(undefined, [ 93 | ts.createVariableDeclaration( 94 | importName, 95 | undefined, 96 | ts.createPropertyAccess(importCall, "default") 97 | ) 98 | ]) 99 | ); 100 | } 101 | if (bindings) { 102 | if (ts.isNamedImports(bindings)) { 103 | // import { a, b } from "aa" 104 | // -> const {a, b} = tsb.import("typescript"); 105 | const elements = bindings.elements.map(v => { 106 | if (v.propertyName) { 107 | return ts.createBindingElement(undefined, v.propertyName, v.name); 108 | } else { 109 | return ts.createBindingElement(undefined, undefined, v.name); 110 | } 111 | }); 112 | ret.push( 113 | ts.createVariableStatement(undefined, [ 114 | ts.createVariableDeclaration( 115 | ts.createObjectBindingPattern(elements), 116 | undefined, 117 | importCall 118 | ) 119 | ]) 120 | ); 121 | } else if (ts.isNamespaceImport(bindings)) { 122 | // import * as ts from "typescript" 123 | // -> const ts = tsb.import("typescript"); 124 | ret.push( 125 | ts.createVariableStatement(undefined, [ 126 | ts.createVariableDeclaration(bindings.name, undefined, importCall) 127 | ]) 128 | ); 129 | } 130 | } 131 | return ret; 132 | } 133 | 134 | transformDynamicImport(node: ts.CallExpression): ts.Node { 135 | if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { 136 | this.hasDynamicImport = true; 137 | const [module] = node.arguments; 138 | let moduleSpecifier: ts.Expression; 139 | if (ts.isStringLiteral(module)) { 140 | moduleSpecifier = ts.createStringLiteral( 141 | this.normalizeModuleSpecifier(module.text) 142 | ); 143 | } else { 144 | moduleSpecifier = createTsbResolveCall(this.moduleId, module); 145 | } 146 | return ts.createCall(createTsbImportDynamicAccess(), node.typeArguments, [ 147 | moduleSpecifier 148 | ]); 149 | } 150 | return node; 151 | } 152 | 153 | transformExportDeclaration( 154 | node: ts.ExportDeclaration 155 | ): ts.VisitResult { 156 | this.shouldMergeExport = true; 157 | const exportClause = node.exportClause; 158 | const module = node.moduleSpecifier; 159 | if (exportClause) { 160 | if (module) { 161 | const keyMap = exportClause.elements.map(v => { 162 | let propertyName: string = v.name.text; 163 | if (v.propertyName) { 164 | propertyName = v.propertyName.text; 165 | } 166 | return ts.createPropertyAssignment( 167 | ts.createStringLiteral(propertyName), 168 | ts.createStringLiteral(v.name.text) 169 | ); 170 | }); 171 | // export {a, b as B} from "..." 172 | // => __export(require("..."), {"a": "a", "b": "B"}) 173 | const text = (module as ts.StringLiteral).text; 174 | return ts.createCall(ts.createIdentifier("__export"), undefined, [ 175 | ts.createCall(createTsbImportAccess(), undefined, [ 176 | ts.createStringLiteral(this.normalizeModuleSpecifier(text)) 177 | ]), 178 | ts.createObjectLiteral(keyMap) 179 | ]); 180 | } else { 181 | const assignments = exportClause.elements.map(v => { 182 | let propertyName: string = v.name.text; 183 | if (v.propertyName) { 184 | propertyName = v.propertyName.text; 185 | return ts.createPropertyAssignment( 186 | propertyName, 187 | ts.createIdentifier(v.name.text) 188 | ); 189 | } else { 190 | return ts.createShorthandPropertyAssignment(propertyName); 191 | } 192 | }); 193 | // export { a, b as B} 194 | // => __export({a, B: b}) 195 | return ts.createCall(ts.createIdentifier("__export"), undefined, [ 196 | ts.createObjectLiteral(assignments) 197 | ]); 198 | } 199 | } else { 200 | const text = (module as ts.StringLiteral).text; 201 | return ts.createCall(ts.createIdentifier("__export"), undefined, [ 202 | ts.createCall(createTsbImportAccess(), undefined, [ 203 | ts.createStringLiteral(this.normalizeModuleSpecifier(text)) 204 | ]) 205 | ]); 206 | } 207 | } 208 | 209 | transformExportAssignment(node: ts.ExportAssignment): ts.Node { 210 | if (node.isExportEquals) { 211 | // export = {} 212 | // -> tsb.exports = {} 213 | return ts.createAssignment(createTsbExportAccess(), node.expression); 214 | } else { 215 | // export default {} 216 | // -> tsb.exports.default = {} 217 | const name = node.name ? node.name.text : "default"; 218 | return ts.createAssignment( 219 | ts.createPropertyAccess(createTsbExportAccess(), name), 220 | node.expression 221 | ); 222 | } 223 | } 224 | 225 | transformExportFunctionDeclaration( 226 | node: ts.FunctionDeclaration 227 | ): ts.VisitResult { 228 | if ( 229 | node.modifiers && 230 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword 231 | ) { 232 | if ( 233 | node.modifiers[1] && 234 | node.modifiers[1].kind === ts.SyntaxKind.DefaultKeyword 235 | ) { 236 | // export default function a() {} 237 | // -> tsb.exports.default = function a() {} 238 | const [_, __, ...rest] = node.modifiers; 239 | return ts.createAssignment( 240 | ts.createPropertyAccess(createTsbExportAccess(), "default"), 241 | ts.createFunctionExpression( 242 | [...rest], 243 | node.asteriskToken, 244 | node.name, 245 | node.typeParameters, 246 | node.parameters, 247 | node.type, 248 | node.body! 249 | ) 250 | ); 251 | } else { 252 | // export function a() {} 253 | // -> 254 | // function a() {} 255 | // tsb.exports.a = a; 256 | const [_, ...rest] = node.modifiers; 257 | return [ 258 | ts.createFunctionExpression( 259 | [...rest], 260 | node.asteriskToken, 261 | node.name, 262 | node.typeParameters, 263 | node.parameters, 264 | node.type, 265 | node.body! 266 | ), 267 | ts.createAssignment( 268 | ts.createPropertyAccess(createTsbExportAccess(), node.name!), 269 | node.name! 270 | ) 271 | ]; 272 | } 273 | } 274 | return node; 275 | } 276 | 277 | transformExportVariableStatement(node: ts.VariableStatement) { 278 | if ( 279 | node.modifiers && 280 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword 281 | ) { 282 | // export const a = {} 283 | // -> 284 | // const a = {} 285 | // export.a = a; 286 | const [_, ...restModifiers] = node.modifiers; 287 | const declarations = ts.createVariableStatement( 288 | restModifiers, 289 | node.declarationList 290 | ); 291 | const exprs = node.declarationList.declarations.map(v => { 292 | return ts.createAssignment( 293 | ts.createPropertyAccess( 294 | createTsbExportAccess(), 295 | (v.name as ts.Identifier).text 296 | ), 297 | v.name as ts.Identifier 298 | ); 299 | }); 300 | return [declarations, ...exprs]; 301 | } 302 | return node; 303 | } 304 | 305 | transformExportClassDeclaration(node: ts.ClassDeclaration) { 306 | if ( 307 | node.modifiers && 308 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword 309 | ) { 310 | let left: ts.Expression; 311 | if ( 312 | node.modifiers[1] && 313 | node.modifiers[1].kind === ts.SyntaxKind.DefaultKeyword 314 | ) { 315 | // export default class Class {} 316 | // -> tsb.exports.default = Class 317 | left = ts.createPropertyAccess(createTsbExportAccess(), "default"); 318 | } else { 319 | // export class Class{} 320 | // -> tsb.exports.Class = Class; 321 | left = ts.createPropertyAccess(createTsbExportAccess(), node.name!); 322 | } 323 | return ts.createAssignment( 324 | left, 325 | ts.createClassExpression( 326 | undefined, 327 | node.name, 328 | node.typeParameters, 329 | node.heritageClauses, 330 | node.members 331 | ) 332 | ); 333 | } 334 | return node; 335 | } 336 | 337 | transformExportEnumDeclaration(node: ts.EnumDeclaration): ts.Node[] { 338 | if ( 339 | node.modifiers && 340 | node.modifiers[0].kind === ts.SyntaxKind.ExportKeyword 341 | ) { 342 | // export enum Enum {} 343 | // -> enum Enum {}, tsb.exports.Enum = Enum 344 | const [_, ...rest] = node.modifiers; 345 | return [ 346 | ts.createEnumDeclaration( 347 | node.decorators, 348 | [...rest], 349 | node.name, 350 | node.members 351 | ), 352 | ts.createAssignment( 353 | ts.createPropertyAccess(createTsbExportAccess(), node.name), 354 | node.name 355 | ) 356 | ]; 357 | } 358 | return [node]; 359 | } 360 | 361 | transformImportMeta(node: ts.MetaProperty): ts.Node { 362 | return ts.createPropertyAccess( 363 | ts.createIdentifier("tsb"), 364 | "meta" 365 | ); 366 | } 367 | 368 | } 369 | -------------------------------------------------------------------------------- /template/template.ts: -------------------------------------------------------------------------------- 1 | function __tsbEntry(modules: { [id: string]: (tsb) => void }, entryId: string) { 2 | interface Tsb { 3 | import(module: string): any; 4 | importDynamic(module: string): Promise; 5 | resolveModule(moduleId: string, dep: string): string; 6 | meta: { 7 | url: string, 8 | isMain: boolean 9 | } 10 | exports: any; 11 | loaded: boolean; 12 | } 13 | const installedModules: Map = new Map(); 14 | const uriRegex = /^http?s:\/\//; 15 | const relativeRegex = /^\.\.?\/.+?\.[tj]s$/; 16 | function resolveModule(moduleId: string, dep: string): string { 17 | if (dep.match(uriRegex)) { 18 | // any + url 19 | return dep; 20 | } else if (moduleId.match(uriRegex)) { 21 | // url + regex 22 | return new URL(dep, moduleId).href; 23 | } else if (moduleId.match(relativeRegex)) { 24 | // relative + relative 25 | const stack = moduleId.split("/"); 26 | const parts = dep.split("/"); 27 | stack.pop(); 28 | for (const part of parts) { 29 | if (part === "..") { 30 | stack.pop(); 31 | } else if (part !== ".") { 32 | stack.push(part); 33 | } 34 | } 35 | return "./" + stack.join("/"); 36 | } else { 37 | throw new Error(`invalid dependency: ${moduleId}, ${dep}`); 38 | } 39 | } 40 | function importInternal(moduleId: string): any { 41 | if (installedModules.has(moduleId)) { 42 | return installedModules.get(moduleId)!.exports; 43 | } 44 | const module: Tsb = { 45 | import: tsbImport, 46 | importDynamic: tsbImportDynamic, 47 | resolveModule, 48 | meta: { 49 | url: new URL(moduleId, "file://").href, 50 | isMain: false 51 | }, 52 | loaded: false, 53 | exports: {} 54 | }; 55 | installedModules.set(moduleId, module); 56 | // execute module in global context 57 | modules[moduleId].call(this, module); 58 | module.loaded = true; 59 | return module.exports; 60 | } 61 | async function tsbImportDynamic(moduleId): Promise { 62 | if (moduleId[moduleId]) { 63 | return importInternal(moduleId); 64 | } else { 65 | // fallback to dynamic import 66 | return import(moduleId); 67 | } 68 | } 69 | function tsbImport(moduleId): any { 70 | return importInternal(moduleId); 71 | } 72 | return tsbImport(entryId); 73 | } 74 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "outDir": "dist", 7 | "strictNullChecks": true 8 | }, 9 | "include": ["src/**/*.ts"], 10 | "exclude": ["src/**/*.test.ts"] 11 | } 12 | --------------------------------------------------------------------------------