Skip to content

Commit

Permalink
fix #527: enable the build API in the browser
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw committed Dec 8, 2020
1 parent 05eaca4 commit 57012a7
Show file tree
Hide file tree
Showing 8 changed files with 678 additions and 13 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@

To avoid making this a breaking change, there is now special behavior for entry point path resolution. If the entry point path exists relative to the current working directory and the path does not start with `./` or `../`, esbuild will now automatically insert a leading `./` at the start of the path to prevent the path from being interpreted as a `node_modules` package path. This is only done if the file actually exists to avoid introducing `./` for paths with special plugin-specific syntax.

* Enable the build API in the browser ([#527](https://github.com/evanw/esbuild/issues/527))

Previously you could only use the transform API in the browser, not the build API. You can now use the build API in the browser too. There is currently no in-browser file system so the build API will not do anything by default. Using this API requires you to use plugins to provide your own file system. Instructions for running esbuild in the browser can be found here: https://esbuild.github.io/api/#running-in-the-browser.

## 0.8.20

* Fix an edge case with class body initialization
Expand Down
18 changes: 13 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ npm/esbuild-wasm/esbuild.wasm: cmd/esbuild/version.go cmd/esbuild/*.go pkg/*/*.g
cp "$(shell go env GOROOT)/misc/wasm/wasm_exec.js" npm/esbuild-wasm/wasm_exec.js
GOOS=js GOARCH=wasm go build -o npm/esbuild-wasm/esbuild.wasm ./cmd/esbuild

# These tests are for development
test:
make -j6 test-go vet-go verify-source-map end-to-end-tests js-api-tests plugin-tests ts-type-tests
make -j6 test-common

# These tests are for development
test-common: test-go vet-go verify-source-map end-to-end-tests js-api-tests plugin-tests

# These tests are for release ("test-wasm" is not included in "test" because it's pretty slow)
# These tests are for release (the extra tests are not included in "test" because they are pretty slow)
test-all:
make -j7 test-go vet-go verify-source-map end-to-end-tests js-api-tests plugin-tests ts-type-tests test-wasm
make -j6 test-common ts-type-tests test-wasm-node test-wasm-browser

# This includes tests of some 3rd-party libraries, which can be very slow
test-prepublish: check-go-version test-all test-preact-splitting test-sucrase bench-rome-esbuild test-esprima test-rollup
Expand All @@ -30,10 +32,13 @@ vet-go:
fmt-go:
go fmt ./cmd/... ./internal/... ./pkg/...

test-wasm: platform-wasm
test-wasm-node: platform-wasm
PATH="$(shell go env GOROOT)/misc/wasm:$$PATH" GOOS=js GOARCH=wasm go test ./internal/...
npm/esbuild-wasm/bin/esbuild --version

test-wasm-browser: platform-wasm | scripts/browser/node_modules
cd scripts/browser && node browser-tests.js

verify-source-map: cmd/esbuild/version.go | scripts/node_modules
cd npm/esbuild && npm version "$(ESBUILD_VERSION)" --allow-same-version
node scripts/verify-source-map.js
Expand Down Expand Up @@ -252,6 +257,9 @@ lib/node_modules:
scripts/node_modules:
cd scripts && npm ci

scripts/browser/node_modules:
cd scripts/browser && npm ci

################################################################################
# This downloads the kangax compat-table and generates browser support mappings

Expand Down
17 changes: 12 additions & 5 deletions lib/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ export const transformSync: typeof types.transformSync = () => {

export const startService: typeof types.startService = options => {
if (!options) throw new Error('Must provide an options object to "startService"');
if (!options.wasmURL) throw new Error('Must provide the "wasmURL" option');
return fetch(options.wasmURL).then(r => r.arrayBuffer()).then(wasm => {
let wasmURL = options.wasmURL;
if (!wasmURL) throw new Error('Must provide the "wasmURL" option');
wasmURL += '';
return fetch(wasmURL).then(res => {
if (!res.ok) throw new Error(`Failed to download ${JSON.stringify(wasmURL)}`);
return res.arrayBuffer();
}).then(wasm => {
let code = `{` +
`let global={};` +
`for(let o=self;o;o=Object.getPrototypeOf(o))` +
Expand Down Expand Up @@ -67,12 +72,14 @@ export const startService: typeof types.startService = options => {
worker.postMessage(bytes)
},
isSync: false,
isBrowser: true,
})

return {
build() {
throw new Error(`The "build" API only works in node`)
},
build: (options: types.BuildOptions): Promise<any> =>
new Promise<types.BuildResult>((resolve, reject) =>
service.buildOrServe(null, options, false, (err, res) =>
err ? reject(err) : resolve(res as types.BuildResult))),
transform: (input, options) =>
new Promise((resolve, reject) =>
service.transform(input, options || {}, false, {
Expand Down
9 changes: 6 additions & 3 deletions lib/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function pushCommonFlags(flags: string[], options: CommonOptions, keys: OptionKe
if (footer) flags.push(`--footer=${footer}`);
}

function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLevelDefault: types.LogLevel):
function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLevelDefault: types.LogLevel, writeDefault: boolean):
[string[], boolean, types.Plugin[] | undefined, string | null, string | null, boolean] {
let flags: string[] = [];
let keys: OptionKeys = Object.create(null);
Expand All @@ -153,7 +153,7 @@ function flagsForBuildOptions(options: types.BuildOptions, isTTY: boolean, logLe
let inject = getFlag(options, keys, 'inject', mustBeArray);
let entryPoints = getFlag(options, keys, 'entryPoints', mustBeArray);
let stdin = getFlag(options, keys, 'stdin', mustBeObject);
let write = getFlag(options, keys, 'write', mustBeBoolean) !== false; // Default to true if not specified
let write = getFlag(options, keys, 'write', mustBeBoolean) ?? writeDefault; // Default to true if not specified
let incremental = getFlag(options, keys, 'incremental', mustBeBoolean) === true;
let plugins = getFlag(options, keys, 'plugins', mustBeArray);
checkForInvalidFlags(options, keys);
Expand Down Expand Up @@ -250,6 +250,7 @@ export interface StreamIn {
writeToStdin: (data: Uint8Array) => void;
readFileSync?: (path: string, encoding: 'utf8') => string;
isSync: boolean;
isBrowser: boolean;
}

export interface StreamOut {
Expand Down Expand Up @@ -619,7 +620,8 @@ export function createChannel(streamIn: StreamIn): StreamOut {
const logLevelDefault = 'info';
try {
let key = nextBuildKey++;
let [flags, write, plugins, stdin, resolveDir, incremental] = flagsForBuildOptions(options, isTTY, logLevelDefault);
let writeDefault = !streamIn.isBrowser;
let [flags, write, plugins, stdin, resolveDir, incremental] = flagsForBuildOptions(options, isTTY, logLevelDefault, writeDefault);
let request: protocol.BuildRequest = { command: 'build', key, flags, write, stdin, resolveDir, incremental };
let serve = serveOptions && buildServeData(serveOptions, request);
let pluginCleanup = plugins && plugins.length > 0 && handlePlugins(plugins, request, key);
Expand Down Expand Up @@ -664,6 +666,7 @@ export function createChannel(streamIn: StreamIn): StreamOut {
return callback(null, result);
};

if (write && streamIn.isBrowser) throw new Error(`Cannot enable "write" in the browser`);
if (incremental && streamIn.isSync) throw new Error(`Cannot use "incremental" with a synchronous build`);
sendRequest<protocol.BuildRequest, protocol.BuildResponse>(request, (error, response) => {
if (error) return callback(new Error(error), null);
Expand Down
2 changes: 2 additions & 0 deletions lib/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export let startService: typeof types.startService = options => {
},
readFileSync: fs.readFileSync,
isSync: false,
isBrowser: false,
});
child.stdout.on('data', readFromStdout);
child.stdout.on('end', afterClose);
Expand Down Expand Up @@ -205,6 +206,7 @@ let runServiceSync = (callback: (service: common.StreamService) => void): void =
stdin = bytes;
},
isSync: true,
isBrowser: false,
});
callback(service);
let stdout = child_process.execFileSync(command, args.concat(`--service=${ESBUILD_VERSION}`), {
Expand Down
236 changes: 236 additions & 0 deletions scripts/browser/browser-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
const puppeteer = require('puppeteer')
const http = require('http')
const path = require('path')
const url = require('url')
const fs = require('fs')

const js = fs.readFileSync(path.join(__dirname, '..', '..', 'npm', 'esbuild-wasm', 'lib', 'browser.js'))
const esm = fs.readFileSync(path.join(__dirname, '..', '..', 'npm', 'esbuild-wasm', 'esm', 'browser.js'))
const wasm = fs.readFileSync(path.join(__dirname, '..', '..', 'npm', 'esbuild-wasm', 'esbuild.wasm'))

// This is converted to a string and run inside the browser
async function runAllTests({ esbuild, service }) {
const tests = {
async transformJS() {
const { code } = await service.transform('1+2')
assertStrictEqual(code, '1 + 2;\n')
},

async transformTS() {
const { code } = await service.transform('1 as any + <any>2', { loader: 'ts' })
assertStrictEqual(code, '1 + 2;\n')
},

async transformCSS() {
const { code } = await service.transform('div { color: red }', { loader: 'css' })
assertStrictEqual(code, 'div {\n color: red;\n}\n')
},

async buildFib() {
const fibonacciPlugin = {
name: 'fib',
setup(build) {
build.onResolve({ filter: /^fib\((\d+)\)/ }, args => {
return { path: args.path, namespace: 'fib' }
})
build.onLoad({ filter: /^fib\((\d+)\)/, namespace: 'fib' }, args => {
let match = /^fib\((\d+)\)/.exec(args.path), n = +match[1]
let contents = n < 2 ? `export default ${n}` : `
import n1 from 'fib(${n - 1}) ${args.path}'
import n2 from 'fib(${n - 2}) ${args.path}'
export default n1 + n2`
return { contents }
})
},
}
const result = await service.build({
stdin: {
contents: `
import x from 'fib(10)'
return x
`,
},
format: 'cjs',
bundle: true,
plugins: [fibonacciPlugin],
})
assertStrictEqual(result.outputFiles.length, 1)
assertStrictEqual(result.outputFiles[0].path, '<stdout>')
const code = result.outputFiles[0].text
const fib10 = new Function(code)()
assertStrictEqual(fib10, 55)
},

async serve() {
expectThrownError(service.serve, 'The "serve" API only works in node')
},

async esbuildBuild() {
expectThrownError(esbuild.build, 'The "build" API only works in node')
},

async esbuildTransform() {
expectThrownError(esbuild.transform, 'The "transform" API only works in node')
},

async esbuildBuildSync() {
expectThrownError(esbuild.buildSync, 'The "buildSync" API only works in node')
},

async esbuildTransformSync() {
expectThrownError(esbuild.transformSync, 'The "transformSync" API only works in node')
},
}

function expectThrownError(fn, err) {
try {
fn()
throw new Error('Expected an error to be thrown')
} catch (e) {
assertStrictEqual(e.message, err)
}
}

function assertStrictEqual(a, b) {
if (a !== b) {
throw new Error(`Assertion failed:
Expected: ${JSON.stringify(a)}
Observed: ${JSON.stringify(b)}`);
}
}

async function runTest(test) {
try {
await tests[test]()
} catch (e) {
testFail(`[${test}] ` + (e && e.message || e))
}
}

const promises = []
for (const test in tests) {
promises.push(runTest(test))
}
await Promise.all(promises)
}

let pages = {
iife: `
<script src="/lib/esbuild.js"></script>
<script>
testStart = function() {
esbuild.startService({ wasmURL: '/esbuild.wasm' }).then(service => {
return (${runAllTests})({ esbuild, service })
}).then(() => {
testDone()
}).catch(e => {
testFail('' + (e && e.stack || e))
testDone()
})
}
</script>
`,
esm: `
<script type="module">
import * as esbuild from '/esm/esbuild.js'
window.testStart = function() {
esbuild.startService({ wasmURL: '/esbuild.wasm' }).then(service => {
return (${runAllTests})({ esbuild, service })
}).then(() => {
testDone()
}).catch(e => {
testFail('' + (e && e.stack || e))
testDone()
})
}
</script>
`,
}

const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url) {
if (req.url === '/lib/esbuild.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' })
res.end(js)
return
}

if (req.url === '/esm/esbuild.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' })
res.end(esm)
return
}

if (req.url === '/esbuild.wasm') {
res.writeHead(200, { 'Content-Type': 'application/wasm' })
res.end(wasm)
return
}

if (req.url.startsWith('/page/')) {
let key = req.url.slice('/page/'.length)
if (Object.prototype.hasOwnProperty.call(pages, key)) {
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(`
<!doctype html>
<meta charset="utf8">
${pages[key]}
`)
return
}
}
}

console.log(`[http] ${req.method} ${req.url}`)
res.writeHead(404)
res.end()
})

server.listen()
const { address, port } = server.address()
const serverURL = url.format({ protocol: 'http', hostname: address, port })
console.log(`[http] listening on ${serverURL}`)

async function main() {
const browser = await puppeteer.launch()
const promises = []
let allTestsPassed = true

async function runPage(key) {
try {
const page = await browser.newPage()
page.on('console', obj => console.log(`[console.${obj.type()}] ${obj.text()}`))
page.exposeFunction('testFail', error => {
console.log(`❌ ${error}`)
allTestsPassed = false
})
let testDone = new Promise(resolve => {
page.exposeFunction('testDone', resolve)
})
await page.goto(`${serverURL}/page/${key}`, { waitUntil: 'domcontentloaded' })
await page.evaluate('testStart()')
await testDone
await page.close()
} catch (e) {
allTestsPassed = false
console.log(`❌ ${key}: ${e && e.message || e}`)
}
}

for (let key in pages) {
promises.push(runPage(key))
}

await Promise.all(promises)
await browser.close()
server.close()

if (!allTestsPassed) {
console.error(`❌ browser test failed`)
process.exit(1)
} else {
console.log(`✅ browser test passed`)
}
}

main().catch(error => setTimeout(() => { throw error }))
Loading

0 comments on commit 57012a7

Please sign in to comment.