diff --git a/cli/js/bundler.ts b/cli/js/bundler.ts new file mode 100644 index 0000000000..4285b61ad4 --- /dev/null +++ b/cli/js/bundler.ts @@ -0,0 +1,113 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. + +import { Console } from "./console.ts"; +import * as dispatch from "./dispatch.ts"; +import { sendSync } from "./dispatch_json.ts"; +import { TextEncoder } from "./text_encoding.ts"; +import { assert, commonPath, humanFileSize } from "./util.ts"; +import { writeFileSync } from "./write_file.ts"; + +declare global { + const console: Console; +} + +const BUNDLE_LOADER = "bundle_loader.js"; + +const encoder = new TextEncoder(); + +let bundleLoader: string; + +let rootExports: string[] | undefined; + +/** Given a fileName and the data, emit the file to the file system. */ +export function emitBundle( + rootNames: string[], + fileName: string | undefined, + data: string, + sourceFiles: readonly ts.SourceFile[] +): void { + // if the fileName is set to an internal value, just noop + if (fileName && fileName.startsWith("$deno$")) { + return; + } + // This should never happen at the moment, but this code can't currently + // support it + assert( + rootNames.length === 1, + "Only single root modules supported for bundling." + ); + if (!bundleLoader) { + bundleLoader = sendSync(dispatch.OP_FETCH_ASSET, { name: BUNDLE_LOADER }); + } + + // when outputting to AMD and a single outfile, TypeScript makes up the module + // specifiers which are used to define the modules, and doesn't expose them + // publicly, so we have to try to replicate + const sources = sourceFiles.map(sf => sf.fileName); + const sharedPath = commonPath(sources); + const rootName = rootNames[0].replace(sharedPath, "").replace(/\.\w+$/i, ""); + let instantiate: string; + if (rootExports && rootExports.length) { + instantiate = `const __rootExports = instantiate("${rootName}");\n`; + for (const rootExport of rootExports) { + if (rootExport === "default") { + instantiate += `export default __rootExports["${rootExport}"];\n`; + } else { + instantiate += `export const ${rootExport} = __rootExports["${rootExport}"];\n`; + } + } + } else { + instantiate = `instantiate("${rootName}");\n`; + } + const bundle = `${bundleLoader}\n${data}\n${instantiate}`; + if (fileName) { + const encodedData = encoder.encode(bundle); + console.warn(`Emitting bundle to "${fileName}"`); + writeFileSync(fileName, encodedData); + console.warn(`${humanFileSize(encodedData.length)} emitted.`); + } else { + console.log(bundle); + } +} + +/** Set the rootExports which will by the `emitBundle()` */ +export function setRootExports( + program: ts.Program, + rootModules: string[] +): void { + // get a reference to the type checker, this will let us find symbols from + // the AST. + const checker = program.getTypeChecker(); + assert(rootModules.length === 1); + // get a reference to the main source file for the bundle + const mainSourceFile = program.getSourceFile(rootModules[0]); + assert(mainSourceFile); + // retrieve the internal TypeScript symbol for this AST node + const mainSymbol = checker.getSymbolAtLocation(mainSourceFile); + if (!mainSymbol) { + return; + } + rootExports = checker + .getExportsOfModule(mainSymbol) + // .getExportsOfModule includes type only symbols which are exported from + // the module, so we need to try to filter those out. While not critical + // someone looking at the bundle would think there is runtime code behind + // that when there isn't. There appears to be no clean way of figuring that + // out, so inspecting SymbolFlags that might be present that are type only + .filter( + sym => + !( + sym.flags & ts.SymbolFlags.Interface || + sym.flags & ts.SymbolFlags.TypeLiteral || + sym.flags & ts.SymbolFlags.Signature || + sym.flags & ts.SymbolFlags.TypeParameter || + sym.flags & ts.SymbolFlags.TypeAlias || + sym.flags & ts.SymbolFlags.Type || + sym.flags & ts.SymbolFlags.Namespace || + sym.flags & ts.SymbolFlags.InterfaceExcludes || + sym.flags & ts.SymbolFlags.TypeParameterExcludes || + sym.flags & ts.SymbolFlags.TypeAliasExcludes + ) + ) + .map(sym => sym.getName()); +} diff --git a/cli/js/compiler.ts b/cli/js/compiler.ts index 1779f76c36..4ad4ae8a43 100644 --- a/cli/js/compiler.ts +++ b/cli/js/compiler.ts @@ -4,6 +4,7 @@ import "./globals.ts"; import "./ts_global.d.ts"; +import { emitBundle, setRootExports } from "./bundler.ts"; import { bold, cyan, yellow } from "./colors.ts"; import { Console } from "./console.ts"; import { core } from "./core.ts"; @@ -12,13 +13,11 @@ import { cwd } from "./dir.ts"; import * as dispatch from "./dispatch.ts"; import { sendAsync, sendSync } from "./dispatch_json.ts"; import * as os from "./os.ts"; -import { TextEncoder } from "./text_encoding.ts"; import { getMappedModuleName, parseTypeDirectives } from "./type_directives.ts"; import { assert, notImplemented } from "./util.ts"; import * as util from "./util.ts"; import { window } from "./window.ts"; import { postMessage, workerClose, workerMain } from "./workers.ts"; -import { writeFileSync } from "./write_file.ts"; // Warning! The values in this enum are duplicated in cli/msg.rs // Update carefully! @@ -52,7 +51,6 @@ window["denoMain"] = denoMain; const ASSETS = "$asset$"; const OUT_DIR = "$deno$"; -const BUNDLE_LOADER = "bundle_loader.js"; /** The format of the work message payload coming from the privileged side */ type CompilerRequest = { @@ -188,6 +186,12 @@ class SourceFile { throw new Error("SourceFile has already been processed."); } assert(this.sourceCode != null); + // we shouldn't process imports for files which contain the nocheck pragma + // (like bundles) + if (this.sourceCode.match(/\/{2}\s+@ts-nocheck/)) { + util.log(`Skipping imports for "${this.filename}"`); + return []; + } const preProcessedFileInfo = ts.preProcessFile(this.sourceCode, true, true); this.processed = true; const files = (this.importedFiles = [] as Array<[string, string]>); @@ -302,63 +306,12 @@ async function processImports( return sourceFiles; } -/** Utility function to turn the number of bytes into a human readable - * unit */ -function humanFileSize(bytes: number): string { - const thresh = 1000; - if (Math.abs(bytes) < thresh) { - return bytes + " B"; - } - const units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; - let u = -1; - do { - bytes /= thresh; - ++u; - } while (Math.abs(bytes) >= thresh && u < units.length - 1); - return `${bytes.toFixed(1)} ${units[u]}`; -} - /** Ops to rest for caching source map and compiled js */ function cache(extension: string, moduleId: string, contents: string): void { util.log("compiler::cache", { extension, moduleId }); sendSync(dispatch.OP_CACHE, { extension, moduleId, contents }); } -const encoder = new TextEncoder(); - -/** Given a fileName and the data, emit the file to the file system. */ -function emitBundle( - rootNames: string[], - fileName: string | undefined, - data: string, - sourceFiles: readonly ts.SourceFile[] -): void { - // For internal purposes, when trying to emit to `$deno$` just no-op - if (fileName && fileName.startsWith("$deno$")) { - console.warn("skipping emitBundle", fileName); - return; - } - const loader = fetchAsset(BUNDLE_LOADER); - // when outputting to AMD and a single outfile, TypeScript makes up the module - // specifiers which are used to define the modules, and doesn't expose them - // publicly, so we have to try to replicate - const sources = sourceFiles.map(sf => sf.fileName); - const sharedPath = util.commonPath(sources); - rootNames = rootNames.map(id => - id.replace(sharedPath, "").replace(/\.\w+$/i, "") - ); - const instantiate = `instantiate(${JSON.stringify(rootNames)});\n`; - const bundle = `${loader}\n${data}\n${instantiate}`; - if (fileName) { - const encodedData = encoder.encode(bundle); - console.warn(`Emitting bundle to "${fileName}"`); - writeFileSync(fileName, encodedData); - console.warn(`${humanFileSize(encodedData.length)} emitted.`); - } else { - console.log(bundle); - } -} - /** Returns the TypeScript Extension enum for a given media type. */ function getExtension(fileName: string, mediaType: MediaType): ts.Extension { switch (mediaType) { @@ -577,7 +530,7 @@ class Host implements ts.CompilerHost { try { assert(sourceFiles != null); if (this._requestType === CompilerRequestType.Bundle) { - emitBundle(this._rootNames, this._outFile, data, sourceFiles!); + emitBundle(this._rootNames, this._outFile, data, sourceFiles); } else { assert(sourceFiles.length == 1); const url = sourceFiles[0].fileName; @@ -704,6 +657,9 @@ window.compilerMain = function compilerMain(): void { // warning so it goes to stderr instead of stdout console.warn(`Bundling "${resolvedRootModules.join(`", "`)}"`); } + if (request.type === CompilerRequestType.Bundle) { + setRootExports(program, resolvedRootModules); + } const emitResult = program.emit(); emitSkipped = emitResult.emitSkipped; // emitResult.diagnostics is `readonly` in TS3.5+ and can't be assigned diff --git a/cli/js/util.ts b/cli/js/util.ts index 54230af1f9..b046a34f48 100644 --- a/cli/js/util.ts +++ b/cli/js/util.ts @@ -248,3 +248,19 @@ export function commonPath(paths: string[], sep = "/"): string { const prefix = parts.slice(0, endOfPrefix).join(sep); return prefix.endsWith(sep) ? prefix : `${prefix}${sep}`; } + +/** Utility function to turn the number of bytes into a human readable + * unit */ +export function humanFileSize(bytes: number): string { + const thresh = 1000; + if (Math.abs(bytes) < thresh) { + return bytes + " B"; + } + const units = ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + let u = -1; + do { + bytes /= thresh; + ++u; + } while (Math.abs(bytes) >= thresh && u < units.length - 1); + return `${bytes.toFixed(1)} ${units[u]}`; +} diff --git a/cli/tests/bundle.test.out b/cli/tests/bundle.test.out index d6f6e8a622..23b7de35e0 100644 --- a/cli/tests/bundle.test.out +++ b/cli/tests/bundle.test.out @@ -14,5 +14,9 @@ define("mod1", ["require", "exports", "subdir2/mod2"], function (require, export [WILDCARD] }); -instantiate(["mod1"]); +const __rootExports = instantiate("mod1"); +export const returnsHi = __rootExports["returnsHi"]; +export const returnsFoo2 = __rootExports["returnsFoo2"]; +export const printHello3 = __rootExports["printHello3"]; +export const throwsError = __rootExports["throwsError"]; diff --git a/cli/tests/integration_tests.rs b/cli/tests/integration_tests.rs index 195202d49c..7fd7a396b7 100644 --- a/cli/tests/integration_tests.rs +++ b/cli/tests/integration_tests.rs @@ -55,6 +55,47 @@ fn js_unit_tests() { drop(g); } +#[test] +fn bundle_exports() { + use tempfile::TempDir; + + // First we have to generate a bundle of some module that has exports. + let mod1 = util::root_path().join("cli/tests/subdir/mod1.ts"); + assert!(mod1.is_file()); + let t = TempDir::new().expect("tempdir fail"); + let bundle = t.path().join("mod1.bundle.js"); + let mut deno = util::deno_cmd() + .current_dir(util::root_path()) + .arg("bundle") + .arg(mod1) + .arg(&bundle) + .spawn() + .expect("failed to spawn script"); + let status = deno.wait().expect("failed to wait for the child process"); + assert!(status.success()); + assert!(bundle.is_file()); + + // Now we try to use that bundle from another module. + let test = t.path().join("test.js"); + std::fs::write( + &test, + " + import { printHello3 } from \"./mod1.bundle.js\"; + printHello3(); ", + ) + .expect("error writing file"); + + let output = util::deno_cmd() + .current_dir(util::root_path()) + .arg("run") + .arg(&test) + .output() + .expect("failed to spawn script"); + // check the output of the test.ts program. + assert_eq!(std::str::from_utf8(&output.stdout).unwrap().trim(), "Hello"); + assert_eq!(output.stderr, b""); +} + // TODO(#2933): Rewrite this test in rust. #[test] fn repl_test() { diff --git a/deno_typescript/bundle_loader.js b/deno_typescript/bundle_loader.js index 6827546283..c56e8c0cb9 100644 --- a/deno_typescript/bundle_loader.js +++ b/deno_typescript/bundle_loader.js @@ -6,14 +6,16 @@ // bundles when creating snapshots, but is also used when emitting bundles from // Deno cli. +// @ts-nocheck + /** - * @type {(name: string, deps: ReadonlyArray, factory: (...deps: any[]) => void) => void} + * @type {(name: string, deps: ReadonlyArray, factory: (...deps: any[]) => void) => void=} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars let define; /** - * @type {(mod: string | string[]) => void} + * @type {(mod: string) => any=} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars let instantiate; @@ -111,14 +113,9 @@ let instantiate; instantiate = dep => { define = undefined; - if (Array.isArray(dep)) { - for (const d of dep) { - getExports(d); - } - } else { - getExports(dep); - } + const result = getExports(dep); // clean up, or otherwise these end up in the runtime environment instantiate = undefined; + return result; }; })(); diff --git a/std/manual.md b/std/manual.md index d2f5f348c4..434d2d17be 100644 --- a/std/manual.md +++ b/std/manual.md @@ -797,27 +797,50 @@ Particularly useful ones: dependencies of the specified input. For example: ``` -> deno bundle https://deno.land/std/examples/colors.ts +> deno bundle https://deno.land/std/examples/colors.ts colors.bundle.js Bundling "colors.bundle.js" Emitting bundle to "colors.bundle.js" 9.2 kB emitted. ``` +If you omit the out file, the bundle will be sent to `stdout`. + The bundle can just be run as any other module in Deno would: ``` deno colors.bundle.js ``` -Bundles can also be loaded in the web browser. For example: +The output is a self contained ES Module, which any exports from the main module +supplied on the command line will be available. For example if the main module +looked something like this: -```html - +```ts +export { foo } from "./foo.js"; + +export const bar = "bar"; ``` -Bundles, whether loaded in the web browser, or in Deno, would run the root -module which is specified on the command line when creating the bundle, so put -any initiation logic in that module. +It could be imported like this: + +```ts +import { foo, bar } from "./lib.bundle.js"; +``` + +Bundles can also be loaded in the web browser. The bundle is a self-contained ES +module, and so the attribute of `type` must be set to `"module"`. For example: + +```html + +``` + +Or you could import it into another ES module to consume: + +```html + +``` ### Installing executable scripts