Skip to content

Commit f778d07

Browse files
authored
[Breaking] Support multiple entrypoints (#19)
* [BREAKING] Support multiple entrypoints / bundles Requires breaking change in plugin API. * Deduplicate transpiled source files
1 parent efb1723 commit f778d07

File tree

9 files changed

+201
-76
lines changed

9 files changed

+201
-76
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ The script can basically be any JavaScript or TypeScript file. It will be bundle
4040
To run Mocha tests:
4141

4242
```sh
43-
npx puppet-run plugin:mocha [...mocha options] ./path/to/*.test.js
43+
npx puppet-run --plugin=mocha [...mocha options] ./path/to/*.test.js
4444
```
4545

4646
Print some help on how to use the tool:
@@ -52,7 +52,7 @@ npx puppet-run --help
5252
Print help text how to use this plugin:
5353

5454
```sh
55-
npx puppet-run plugin:mocha --help
55+
npx puppet-run --plugin=mocha --help
5656
```
5757

5858

@@ -106,15 +106,15 @@ Here is how to use the mocha plugin:
106106

107107
```sh
108108
npm install puppet-run-plugin-mocha
109-
npx puppet-run plugin:mocha ./*.test.js [--reporter "spec"]
109+
npx puppet-run --plugin=mocha ./*.test.js [--reporter "spec"]
110110
```
111111

112112
This way you can just pass an arbitrary usual mocha test file without having to care about `puppet.exit()` or any boilerplate code.
113113

114114
You can also get help how to use a plugin:
115115

116116
```sh
117-
npx puppet-run plugin:mocha --help
117+
npx puppet-run --plugin=mocha --help
118118
```
119119

120120

package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"meow": "^5.0.0",
3838
"minimist": "^1.2.0",
3939
"mkdirp": "^0.5.1",
40+
"nanoid": "^2.0.3",
4041
"ora": "^3.0.0",
4142
"puppeteer-core": "^1.10.0",
4243
"rimraf": "^2.6.2",
@@ -52,6 +53,7 @@
5253
"@types/get-port": "^4.0.0",
5354
"@types/meow": "^5.0.0",
5455
"@types/mkdirp": "^0.5.2",
56+
"@types/nanoid": "^2.0.0",
5557
"@types/node": "^10.9.4",
5658
"@types/ora": "^1.3.4",
5759
"@types/puppeteer-core": "^1.9.0",

src/bundle.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@ import * as path from "path"
33
import babelify from "babelify"
44
import browserify from "browserify"
55
import envify from "envify"
6+
import mkdirp from "mkdirp"
7+
import nanoid from "nanoid"
68
import { TemporaryFileCache } from "./temporary"
9+
import { Entrypoint } from "./types"
710

8-
export async function createBundle (entryPaths: string[], cache: TemporaryFileCache) {
11+
export async function createBundle (entry: Entrypoint, cache: TemporaryFileCache): Promise<Entrypoint> {
912
// TODO: Use persistent cache
1013

11-
const bundleFilePath = path.join(cache, "main.js")
14+
const servePath = (entry.servePath || `${path.basename(entry.sourcePath)}-${nanoid(6)}`).replace(/\.(jsx?|tsx?)/i, ".js")
15+
const bundleFilePath = path.join(cache, servePath)
1216
const extensions = ["", ".js", ".jsx", ".ts", ".tsx", ".json"]
1317

18+
mkdirp.sync(path.dirname(bundleFilePath))
19+
1420
await new Promise(resolve => {
1521
const stream = browserify({
1622
debug: true, // enables inline sourcemaps
17-
entries: entryPaths,
23+
entries: [entry.sourcePath],
1824
extensions
1925
})
2026
.transform(babelify.configure({
@@ -34,5 +40,8 @@ export async function createBundle (entryPaths: string[], cache: TemporaryFileCa
3440
stream.on("finish", resolve)
3541
})
3642

37-
return "main.js"
43+
return {
44+
servePath,
45+
sourcePath: bundleFilePath
46+
}
3847
}

src/cli.ts

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,32 @@ import meow from "meow"
55
import minimist from "minimist"
66
import ora from "ora"
77
import path from "path"
8-
import * as Stream from "stream"
98
import { createBundle } from "./bundle"
10-
import { copyFiles } from "./fs"
11-
import { isPluginArgument, loadPlugin, printPluginHelp } from "./plugins"
9+
import { copyFiles, dedupeSourceFiles, resolveDirectoryEntrypoints } from "./fs"
10+
import { loadPlugin, printPluginHelp, resolveEntrypoints } from "./plugins"
1211
import { spawnPuppet } from "./puppeteer"
1312
import { serveDirectory } from "./server"
1413
import { clearTemporaryFileCache, createTemporaryFileCache, writeBlankHtmlPage } from "./temporary"
14+
import { Entrypoint } from "./types"
1515

1616
const cli = meow(`
1717
Usage
18-
$ puppet-run <./path/to/index.js> [...script arguments]
19-
$ puppet-run plugin:<plugin> <...plugin arguments>
18+
$ puppet-run <./entrypoint> [...more entrypoints] [-- <...script arguments>]
19+
$ puppet-run <./entrypoint>:</serve/here> [...more entrypoints] [-- <...script args>]
20+
$ puppet-run --plugin=<plugin> [<...entrypoints>] [-- <...script arguments>]
2021
2122
Options
2223
--help Show this help.
2324
--inspect Run in actual Chrome window and keep it open.
25+
--bundle <./file>[:</serve/here>] Bundle and serve additional files, but don't inject them.
2426
--p <port>, --port <port> Serve on this port. Defaults to random port.
27+
--plugin <plugin> Load and apply plugin <plugin>.
2528
--serve <./file>[:</serve/here>] Serve additional files next to bundle.
2629
2730
Example
2831
$ puppet-run ./sample/cowsays.js
2932
$ puppet-run ./sample/greet.ts newbie
30-
$ puppet-run plugin:mocha ./sample/mocha-test.ts
33+
$ puppet-run --plugin=mocha ./sample/mocha-test.ts
3134
`, {
3235
autoHelp: false
3336
})
@@ -42,6 +45,14 @@ function ensureArray (arg: string | string[] | undefined): string[] {
4245
}
4346
}
4447

48+
function parseEntrypointArg (arg: string): Entrypoint {
49+
const [sourcePath, servePath] = arg.split(":")
50+
return {
51+
servePath,
52+
sourcePath
53+
}
54+
}
55+
4556
async function withSpinner<T>(promise: Promise<T>): Promise<T> {
4657
const spinner = ora("Bundling code").start()
4758

@@ -55,55 +66,63 @@ async function withSpinner<T>(promise: Promise<T>): Promise<T> {
5566
}
5667
}
5768

58-
const isParameterizedOption = (arg: string) => ["-p", "--port", "--serve"].indexOf(arg) > -1
59-
const firstArgumentIndex = process.argv.findIndex(
60-
(arg, index) => index >= 2 && !arg.startsWith("-") && !isParameterizedOption(process.argv[index - 1])
61-
)
62-
63-
const runnerOptionArgs = firstArgumentIndex > -1 ? process.argv.slice(2, firstArgumentIndex) : process.argv.slice(2)
64-
const scriptArgs = firstArgumentIndex > -1 ? process.argv.slice(firstArgumentIndex + 1) : []
69+
const argsSeparatorIndex = process.argv.indexOf("--")
70+
const runnerOptionArgs = argsSeparatorIndex > -1 ? process.argv.slice(2, argsSeparatorIndex) : process.argv.slice(2)
71+
const scriptArgs = argsSeparatorIndex > -1 ? process.argv.slice(argsSeparatorIndex + 1) : []
6572

6673
const runnerOptions = minimist(runnerOptionArgs)
6774

68-
const additionalFilesToServe = ensureArray(runnerOptions.serve).map(arg => {
69-
const [sourcePath, servingPath] = arg.split(":")
70-
return { sourcePath, servingPath: servingPath || path.basename(arg) }
71-
})
75+
const pluginNames = Array.isArray(runnerOptions.plugin || [])
76+
? runnerOptions.plugin || []
77+
: [runnerOptions.plugin]
78+
79+
const plugins = pluginNames.map(loadPlugin)
7280

73-
if (firstArgumentIndex === -1 || runnerOptionArgs.indexOf("--help") > -1) {
81+
if (runnerOptionArgs.indexOf("--help") > -1 && plugins.length > 0) {
82+
printPluginHelp(plugins[0], scriptArgs)
83+
process.exit(0)
84+
} else if (process.argv.length === 2 || runnerOptionArgs.indexOf("--help") > -1) {
7485
cli.showHelp()
7586
process.exit(0)
7687
}
7788

78-
async function run () {
89+
async function run() {
7990
let exitCode = 0
80-
const entrypoint = process.argv[firstArgumentIndex]
8191

8292
const headless = runnerOptionArgs.indexOf("--inspect") > -1 ? false : true
8393
const port = runnerOptions.p || runnerOptions.port
8494
? parseInt(runnerOptions.p || runnerOptions.port, 10)
8595
: await getPort()
8696

87-
const plugin = isPluginArgument(entrypoint) ? loadPlugin(entrypoint) : null
88-
const temporaryCache = createTemporaryFileCache()
97+
const additionalBundleEntries = await resolveDirectoryEntrypoints(
98+
ensureArray(runnerOptions.bundle).map(parseEntrypointArg),
99+
filenames => dedupeSourceFiles(filenames, true)
100+
)
101+
const additionalFilesToServe = await resolveDirectoryEntrypoints(ensureArray(runnerOptions.serve).map(parseEntrypointArg))
89102

90-
if (plugin && scriptArgs.indexOf("--help") > -1) {
91-
return printPluginHelp(plugin, scriptArgs)
92-
}
103+
const entrypointArgs = runnerOptionArgs.filter(arg => arg.charAt(0) !== "-")
104+
const entrypoints = await resolveEntrypoints(plugins, entrypointArgs.map(parseEntrypointArg), scriptArgs)
93105

94-
const scriptPaths = plugin && plugin.resolveBundleEntrypoints
95-
? await plugin.resolveBundleEntrypoints(scriptArgs)
96-
: [ entrypoint ]
106+
const temporaryCache = createTemporaryFileCache()
97107

98108
try {
99109
const serverURL = `http://localhost:${port}/`
100110
writeBlankHtmlPage(path.join(temporaryCache, "index.html"))
101111

102-
const bundle = await withSpinner(createBundle(scriptPaths, temporaryCache))
103-
await copyFiles(additionalFilesToServe, temporaryCache)
112+
const allBundles = await withSpinner(
113+
Promise.all([...entrypoints, ...additionalBundleEntries].map(entrypoint => {
114+
return createBundle(entrypoint, temporaryCache)
115+
}))
116+
)
117+
118+
const startupBundles = allBundles.slice(0, entrypoints.length)
119+
const lazyBundles = allBundles.slice(entrypoints.length)
120+
121+
await copyFiles([...additionalFilesToServe, ...lazyBundles], temporaryCache)
122+
104123
const closeServer = await serveDirectory(temporaryCache, port)
105-
const puppet = await spawnPuppet(bundle, serverURL, { headless })
106-
await puppet.run(scriptArgs, plugin)
124+
const puppet = await spawnPuppet(startupBundles.map(entry => entry.servePath!), serverURL, { headless })
125+
await puppet.run(scriptArgs, plugins)
107126

108127
exitCode = await puppet.waitForExit()
109128
await puppet.close()

src/fs.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
import * as fs from "fs"
22
import * as path from "path"
33
import mkdirp from "mkdirp"
4+
import * as util from "util"
5+
import { Entrypoint } from "./types"
46

5-
interface FileToServe {
6-
servingPath: string,
7-
sourcePath: string
7+
const passthrough = <T>(thing: T) => thing
8+
9+
const readdir = util.promisify(fs.readdir)
10+
const stat = util.promisify(fs.stat)
11+
12+
function flatten<T>(nested: T[][]): T[] {
13+
return nested.reduce<T[]>(
14+
(flattened, subarray) => [...flattened, ...subarray],
15+
[]
16+
)
817
}
918

1019
export function copyFile (from: string, to: string) {
20+
if (path.resolve(from) === path.resolve(to)) return
21+
1122
return new Promise(resolve => {
1223
const input = fs.createReadStream(from)
1324
const output = fs.createWriteStream(to)
@@ -17,28 +28,69 @@ export function copyFile (from: string, to: string) {
1728
})
1829
}
1930

20-
export async function copyFiles (filesToServe: FileToServe[], destinationDirectory: string) {
31+
export async function copyFiles(filesToServe: Entrypoint[], destinationDirectory: string) {
2132
return Promise.all(filesToServe.map(
22-
async ({ servingPath, sourcePath }) => {
33+
async ({ servePath, sourcePath }) => {
34+
const servingPath = servePath || path.basename(sourcePath)
2335
const destinationFilePath = path.resolve(destinationDirectory, servingPath.replace(/^\//, ""))
36+
2437
if (destinationFilePath.substr(0, destinationDirectory.length) !== destinationDirectory) {
2538
throw new Error(`File would be served outside of destination directory: ${sourcePath} => ${servingPath}`)
2639
}
2740

2841
mkdirp.sync(path.dirname(destinationFilePath))
42+
await copyFile(sourcePath, destinationFilePath)
43+
}
44+
))
45+
}
46+
47+
export function dedupeSourceFiles(basenames: string[], dropNonSourceFiles?: boolean): string[] {
48+
// We don't want to include a source file and its already transpiled version as input
49+
50+
const sourceExtensionsRegex = /\.(jsx?|tsx?)$/i
51+
const sourceFileNames = basenames.filter(basename => basename.match(sourceExtensionsRegex))
52+
const nonSourceFileNames = basenames.filter(basename => sourceFileNames.indexOf(basename) === -1)
2953

30-
if (fs.statSync(sourcePath).isDirectory()) {
31-
const directoryFiles = fs.readdirSync(sourcePath)
32-
await copyFiles(
33-
directoryFiles.map(file => ({
34-
servingPath: path.join(servingPath, file),
35-
sourcePath: path.join(sourcePath, file)
36-
})),
37-
destinationDirectory
38-
)
54+
const collidingSourceFileNames = sourceFileNames.reduce<{ [name: string]: string[] }>(
55+
(destructured, filename) => {
56+
const ext = path.extname(filename)
57+
const name = filename.substr(0, filename.length - ext.length)
58+
return {
59+
...destructured,
60+
[name]: (destructured[name] || []).concat([ext])
61+
}
62+
},
63+
{}
64+
)
65+
66+
const dedupedSourceFileNames = Object.keys(collidingSourceFileNames).map(name => {
67+
const ext = collidingSourceFileNames[name].sort()[0]
68+
return `${name}${ext}`
69+
})
70+
71+
return [
72+
...(dropNonSourceFiles ? [] : nonSourceFileNames),
73+
...dedupedSourceFileNames
74+
]
75+
}
76+
77+
export async function resolveDirectoryEntrypoints(
78+
entrypoints: Entrypoint[],
79+
filterFiles: (basenames: string[]) => string[] = passthrough
80+
): Promise<Entrypoint[]> {
81+
const nested = await Promise.all(
82+
entrypoints.map(async entry => {
83+
if ((await stat(entry.sourcePath)).isDirectory()) {
84+
const files = filterFiles(await readdir(entry.sourcePath))
85+
const subentries = files.map(filename => ({
86+
servePath: entry.servePath ? path.join(entry.servePath, filename) : undefined,
87+
sourcePath: path.join(entry.sourcePath, filename)
88+
}))
89+
return resolveDirectoryEntrypoints(subentries)
3990
} else {
40-
await copyFile(sourcePath, destinationFilePath)
91+
return [entry]
4192
}
42-
}
43-
))
93+
})
94+
)
95+
return flatten(nested)
4496
}

0 commit comments

Comments
 (0)