Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support modules with --no-bundle #2769

Merged
merged 27 commits into from
Mar 27, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
84c9e71
Support modules with `--no-bundle`
penalosa Feb 20, 2023
9899192
Fix package locks
penalosa Feb 20, 2023
8b67243
More tests
penalosa Feb 20, 2023
1d6a6e2
Fix tests
penalosa Feb 20, 2023
1be1903
Increase test timeout
penalosa Feb 20, 2023
b577735
Support data modules
penalosa Feb 21, 2023
ab9ea6e
Pull out common ESBuild options
penalosa Feb 21, 2023
b925ec8
Remove extraneous comments
penalosa Feb 21, 2023
2f7fc20
Create beige-eagles-wave.md
penalosa Feb 21, 2023
5be3df0
Update beige-eagles-wave.md
penalosa Feb 21, 2023
58b6a0f
Merge branch 'main' into penalosa/no-bundle-modules
penalosa Feb 21, 2023
b268495
Hard error on non string-literal dynamic imports
penalosa Feb 22, 2023
b1a85c7
upper snake case
penalosa Feb 22, 2023
ab50e01
bundle type extraction
penalosa Feb 22, 2023
d715f59
Merge branch 'main' into penalosa/no-bundle-modules
penalosa Feb 24, 2023
bf59ecf
Merge branch 'main' into penalosa/no-bundle-modules
penalosa Feb 27, 2023
eb16a46
Use module rules
penalosa Mar 14, 2023
e70b587
Fix CI and add additional tests
penalosa Mar 15, 2023
80896d2
fixup: lint module import ordering
penalosa Mar 15, 2023
d7a6aba
Skip tests on Windows
penalosa Mar 15, 2023
be45c79
fixup: PR comments, and additional tests
penalosa Mar 15, 2023
36666cd
fixup: PR comments, and additional tests
penalosa Mar 17, 2023
6bbe79b
fixup: parsing
penalosa Mar 22, 2023
c0bc8f3
fixup! improve tests
penalosa Mar 27, 2023
203e212
fixup! exapnd changeset
penalosa Mar 27, 2023
88e98c4
Merge branch 'main' into penalosa/no-bundle-modules
penalosa Mar 27, 2023
b7e71a6
fixup! expand changeset+formatting
penalosa Mar 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/beige-eagles-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"wrangler": minor
"no-bundle-import": patch
---

feature: Support modules with `--no-bundle`

When the `--no-bundle` flag is set, Wrangler now has support for traversing the module graph with `esbuild`, to figure out what additional modules should be uploaded alongside the entrypoint. This will allow modules to be imported at runtime on Cloudflare's Edge. This also respects Wrangler's [module rules](https://developers.cloudflare.com/workers/wrangler/configuration/#bundling) configuration, which means that importing non-JS modules will also trigger an upload. For instance, the following code will now work with `--no-bundle` (assuming the `example.wasm` file exists at the correct path):
penalosa marked this conversation as resolved.
Show resolved Hide resolved

```js
// index.js
import wasm from './example.wasm'

export default {
async fetch() {
await WebAssembly.instantiate(wasm, ...)
...
}
}
```
14 changes: 14 additions & 0 deletions fixtures/no-bundle-import/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "no-bundle-import",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler publish",
"start": "wrangler dev",
"test": "vitest"
},
"devDependencies": {
"get-port": "^6.1.2",
"wrangler": "^2.10.0"
}
}
Binary file added fixtures/no-bundle-import/src/data.bin
Binary file not shown.
1 change: 1 addition & 0 deletions fixtures/no-bundle-import/src/data.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TEST DATA
petebacondarwin marked this conversation as resolved.
Show resolved Hide resolved
1 change: 1 addition & 0 deletions fixtures/no-bundle-import/src/dynamic-var.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "dynamic var";
1 change: 1 addition & 0 deletions fixtures/no-bundle-import/src/dynamic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default "dynamic";
63 changes: 63 additions & 0 deletions fixtures/no-bundle-import/src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { sayHello } from "./say-hello.js";

import { johnSmith } from "./nested/index.js";
import WASM from "./simple.wasm";
import nestedWasm from "./nested/simple.wasm";

import text from "./data.txt";
import binData from "./data.bin";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === "/dynamic") {
return new Response(`${(await import("./dynamic.js")).default}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT - I feel "lazy" is more helpful here than "dynamic" even though I appreciate that this is the official term for an import() expression like this. The reason is that there is a qualitative difference between await import("some-module") and let x = "some-module"; await import(x), since the former can be analysed statically by esbuild and the latter cannot.

}
if (url.pathname === "/dynamic-var") {
const name = "./dynamic-var.js";
return new Response(`${(await import(name)).default}`);
}
if (url.pathname === "/wasm") {
return new Response(
await new Promise(async (resolve) => {
const moduleImport = {
imports: {
imported_func(arg) {
resolve(arg);
},
},
};
const module1 = await WebAssembly.instantiate(WASM, moduleImport);
module1.exports.exported_func();
})
);
}
if (url.pathname === "/wasm-nested") {
return new Response(
await new Promise(async (resolve) => {
const moduleImport = {
imports: {
imported_func(arg) {
resolve("nested" + arg);
},
},
};
const m = await WebAssembly.instantiate(nestedWasm, moduleImport);
m.exports.exported_func();
})
);
}
if (url.pathname === "/wasm-dynamic") {
return new Response(
`${await (await import("./nested/index.js")).loadWasm()}`
);
}

if (url.pathname === "/txt") {
return new Response(text);
}
if (url.pathname === "/bin") {
return new Response(binData);
}
return new Response(`${sayHello("Jane Smith")} and ${johnSmith}`);
},
};
68 changes: 68 additions & 0 deletions fixtures/no-bundle-import/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import path from "path";
import getPort from "get-port";
import { describe, expect, test, beforeAll, afterAll } from "vitest";
import { unstable_dev } from "../../../packages/wrangler/wrangler-dist/cli.js";
import type { UnstableDevWorker } from "../../../packages/wrangler/wrangler-dist/cli.js";

describe("Worker", () => {
let worker: UnstableDevWorker;

beforeAll(async () => {
worker = await unstable_dev(path.resolve(__dirname, "index.js"), {
bundle: false,
port: await getPort(),
mrbbot marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the same bug preventing you from just leaving this undefined?

experimental: { experimentalLocal: true },
});
}, 30_000);

afterAll(() => worker.stop());

test("module traversal results in correct response", async () => {
const resp = await worker.fetch();
const text = await resp.text();
expect(text).toMatchInlineSnapshot(
`"Hello Jane Smith and Hello John Smith"`
);
});

test("support for dynamic imports", async () => {
const resp = await worker.fetch("/dynamic");
const text = await resp.text();
expect(text).toMatchInlineSnapshot(`"dynamic"`);
});

test("no support for variable dynamic imports", async () => {
const resp = await worker.fetch("/dynamic-var");
const text = await resp.text();
expect(text).toMatchInlineSnapshot(
'"Error: No such module \\"dynamic-var.js\\"."'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this the right error message? oh.. wait.. are we throwing this error because we failed to upload that file and then runtime couldn't find it, hence this error message?

would it be possible for the --no-bundle code to fail if we can't resolve dynamic imports? As a developer I'd rather see a build error than runtime error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this gets all the way to the runtime. Unfortunately, esbuild doesn't provide any hooks for dynamic variable imports—it seems to just ignore them (see https://esbuild.github.io/api/#non-analyzable-imports), so I'm not sure there's much we can do here. This might be possible to support in future somehow with https://www.npmjs.com/package/es-module-lexer though

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we can't find a way for --no-bundle to fail when dynamic imports can't be resolved, could this error message read something like:

"Error: The dynamically imported ES module \"dynamic-var.js"\ could not be found. Run wrangler --dry-run --outdir --no-bundle and verify that this module was generated when building your worker"

...such that we give people instructions on how to debug? (not sure if I've got the instructions quite right above, but you get the gist). How can we give people an error message they can act on and debug on their own?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure—this error comes from the runtime, so we may not want to call out Wrangler specifically. Maybe something like "Error: The dynamically imported ES module "dynamic-var.js"\ could not be found. Make sure it was uploaded when you published your Worker"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to ask about [es-module-lexer](https://www.npmjs.com/package/es-module-lexer)... could you please do a lightweight exploration if implementing this feature via es-module-lexer would allow us to turn the runtime error into build time error?

If that was possible and there were no other major blockers, I'd be inclined to going down that path as runtime errors are really really nasty and we should avoid them whenever we can even if it means a little more work for us — it will save many times more work to developers that would otherwise scramble to deal with runtime errors.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed up an additional commit that adds a build-time error for non string-literal dynamic imports. It uses es-module-lexer in addition to esbuild, and so has a slight performance cost over just using esbuild (but that should be minimal). They're both needed in order to be able to run Wrangler's module collection (which is implemented as an esbuild plugin), and to avoid re-implementing esbuild's module resolution (reading tsconfig, for instance)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Interesting. How significant is the overhead? Do you have any estimates? <5ms for an average project, or more? Unless it's overhead measured in hundreds of ms, I think it's totally worth it. I am hoping for single or at most double digit ms...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's pretty minimal. es-module-lexer has benchmarks which estimate 5-10ms as an upper bound for parsing a MB of JS, but it will scale with the number of modules. For a large worker (let's say 25 modules at 200kb, which is at the 5MB limit for workers), es-module-lexer will probably add about 50ms of latency in the worst case (with a fairly pessimistic reading of their benchmarks).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an average project (let's say 5 modules at 100kb), the overhead would be closer to ~5ms

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. Then this overhead is totally worth it. Thanks

);
});
test("basic wasm support", async () => {
const resp = await worker.fetch("/wasm");
const text = await resp.text();
expect(text).toMatchInlineSnapshot('"42"');
});
test("resolves wasm import paths relative to root", async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: to space or not to space (out the test() calls) that is the question.

const resp = await worker.fetch("/wasm-nested");
const text = await resp.text();
expect(text).toMatchInlineSnapshot('"nested42"');
});
test("wasm can be imported from a dynamic import", async () => {
const resp = await worker.fetch("/wasm-dynamic");
const text = await resp.text();
expect(text).toMatchInlineSnapshot('"sibling42subdirectory42"');
});
test("text data can be imported", async () => {
const resp = await worker.fetch("/txt");
const text = await resp.text();
expect(text).toMatchInlineSnapshot('"TEST DATA"');
});
test("binary data can be imported", async () => {
const resp = await worker.fetch("/bin");
const bin = await resp.arrayBuffer();
const expected = new Uint8Array(new ArrayBuffer(4));
expected.set([0, 1, 2, 10]);
expect(new Uint8Array(bin)).toEqual(expected);
});
});
31 changes: 31 additions & 0 deletions fixtures/no-bundle-import/src/nested/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { sayHello } from "../say-hello.js";
import subWasm from "../simple.wasm";
import sibWasm from "./simple.wasm";
export const johnSmith = sayHello("John Smith");

export async function loadWasm() {
const sibling = await new Promise(async (resolve) => {
const moduleImport = {
imports: {
imported_func(arg) {
resolve("sibling" + arg);
},
},
};
const m = await WebAssembly.instantiate(sibWasm, moduleImport);
m.exports.exported_func();
});

const subdirectory = await new Promise(async (resolve) => {
const moduleImport = {
imports: {
imported_func(arg) {
resolve("subdirectory" + arg);
},
},
};
const m = await WebAssembly.instantiate(subWasm, moduleImport);
m.exports.exported_func();
});
return sibling + subdirectory;
}
Binary file added fixtures/no-bundle-import/src/nested/simple.wasm
Binary file not shown.
1 change: 1 addition & 0 deletions fixtures/no-bundle-import/src/say-hello.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const sayHello = (name) => `Hello ${name}`;
Binary file added fixtures/no-bundle-import/src/simple.wasm
Binary file not shown.
3 changes: 3 additions & 0 deletions fixtures/no-bundle-import/wrangler.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name = "no-bundle-import"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this no-bundle-import fixture could benefit from a README that explains what it is exercising.

main = "src/index.js"
compatibility_date = "2023-02-20"
Loading