Skip to content

Commit

Permalink
Merge pull request #3081 from cloudflare/jsnell/disable-top-level-await
Browse files Browse the repository at this point in the history
  • Loading branch information
jasnell authored Nov 13, 2024
2 parents af6f607 + e858332 commit 12479a1
Show file tree
Hide file tree
Showing 13 changed files with 179 additions and 108 deletions.
9 changes: 1 addition & 8 deletions src/workerd/api/global-scope.c++
Original file line number Diff line number Diff line change
Expand Up @@ -849,15 +849,8 @@ jsg::JsValue resolveFromRegistry(jsg::Lock& js, kj::StringPtr specifier) {
auto& info = JSG_REQUIRE_NONNULL(
moduleRegistry->resolve(js, spec), Error, kj::str("No such module: ", specifier));
auto module = info.module.getHandle(js);
jsg::instantiateModule(js, module);

auto handle = jsg::check(module->Evaluate(js.v8Context()));
KJ_ASSERT(handle->IsPromise());
auto prom = handle.As<v8::Promise>();
KJ_ASSERT(prom->State() != v8::Promise::PromiseState::kPending);
if (module->GetStatus() == v8::Module::kErrored) {
jsg::throwTunneledException(js.v8Isolate, module->GetException());
}
jsg::instantiateModule(js, module);
return jsg::JsValue(js.v8Get(module->GetModuleNamespace().As<v8::Object>(), "default"_kj));
}
} // namespace
Expand Down
31 changes: 6 additions & 25 deletions src/workerd/api/node/module.c++
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// https://opensource.org/licenses/Apache-2.0
#include "module.h"

#include <workerd/io/features.h>
#include <workerd/jsg/url.h>

namespace workerd::api::node {
Expand Down Expand Up @@ -80,33 +81,13 @@ jsg::JsValue ModuleUtil::createRequire(jsg::Lock& js, kj::String path) {
jsg::ModuleRegistry::ResolveMethod::REQUIRE, spec.asPtr()),
Error, "No such module \"", targetPath.toString(), "\".");

bool isEsm = info.maybeSynthetic == kj::none;

auto module = info.module.getHandle(js);

jsg::instantiateModule(js, module);
auto handle = jsg::check(module->Evaluate(js.v8Context()));
KJ_ASSERT(handle->IsPromise());
auto prom = handle.As<v8::Promise>();
if (prom->State() == v8::Promise::PromiseState::kPending) {
js.runMicrotasks();
}
JSG_REQUIRE(prom->State() != v8::Promise::PromiseState::kPending, Error,
"Module evaluation did not complete synchronously.");
if (module->GetStatus() == v8::Module::kErrored) {
jsg::throwTunneledException(js.v8Isolate, module->GetException());
}

if (isEsm) {
// If the import is an esm module, we will return the namespace object.
jsg::JsObject obj(module->GetModuleNamespace().As<v8::Object>());
if (obj.get(js, "__cjsUnwrapDefault"_kj) == js.boolean(true)) {
return obj.get(js, "default"_kj);
}
return obj;
jsg::ModuleRegistry::RequireImplOptions options =
jsg::ModuleRegistry::RequireImplOptions::DEFAULT;
if (info.maybeSynthetic != kj::none) {
options = jsg::ModuleRegistry::RequireImplOptions::EXPORT_DEFAULT;
}

return jsg::JsValue(js.v8Get(module->GetModuleNamespace().As<v8::Object>(), "default"_kj));
return jsg::ModuleRegistry::requireImpl(js, info, options);
}));
}

Expand Down
9 changes: 8 additions & 1 deletion src/workerd/api/node/tests/module-create-require-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ export const doTheTest = {
strictEqual(assert, required);

throws(() => require('invalid'), {
message: 'Module evaluation did not complete synchronously.',
message: 'Top-level await in module is not permitted at this time.',
});
// Trying to require the module again should throw the same error.
throws(() => require('invalid'), {
message: 'Top-level await in module is not permitted at this time.',
});
throws(() => require('invalid2'), {
message: 'Top-level await in module is not permitted at this time.',
});

throws(() => require('does not exist'));
Expand Down
6 changes: 4 additions & 2 deletions src/workerd/api/node/tests/module-create-require-test.wd-test
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ const unitTests :Workerd.Config = (
(name = "bar", esModule = "export default 2; export const __cjsUnwrapDefault = true;"),
(name = "baz", commonJsModule = "module.exports = 3;"),
(name = "worker/qux", text = "4"),
(name = "invalid", esModule = "const p = new Promise(() => {}); await p;"),
(name = "invalid", esModule = "await new Promise(() => {});"),
(name = "invalid2", commonJsModule = "require('invalid3');"),
(name = "invalid3", esModule = "await new Promise(() => {});"),
],
compatibilityDate = "2024-08-01",
compatibilityFlags = ["nodejs_compat_v2"]
compatibilityFlags = ["disable_top_level_await_in_require", "nodejs_compat_v2"]
)
),
],
Expand Down
7 changes: 0 additions & 7 deletions src/workerd/api/node/util.c++
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,6 @@ jsg::JsValue UtilModule::getBuiltinModule(jsg::Lock& js, kj::String specifier) {
jsg::ModuleRegistry::ResolveMethod::IMPORT, rawSpecifier.asPtr())) {
auto module = info.module.getHandle(js);
jsg::instantiateModule(js, module);
auto handle = jsg::check(module->Evaluate(js.v8Context()));
KJ_ASSERT(handle->IsPromise());
auto prom = handle.As<v8::Promise>();
KJ_ASSERT(prom->State() != v8::Promise::PromiseState::kPending);
if (module->GetStatus() == v8::Module::kErrored) {
jsg::throwTunneledException(js.v8Isolate, module->GetException());
}

// For Node.js modules, we want to grab the default export and return that.
// For other built-ins, we'll return the module namespace instead. Can be
Expand Down
2 changes: 1 addition & 1 deletion src/workerd/api/tests/new-module-registry-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { foo, default as def } from 'foo';
import { default as fs } from 'node:fs';
import { Buffer } from 'buffer';
const { foo: foo2, default: def2 } = await import('bar');
import { foo as foo2, default as def2 } from 'bar';

// Verify that import.meta.url is correct here.
strictEqual(import.meta.url, 'file:///worker');
Expand Down
8 changes: 8 additions & 0 deletions src/workerd/io/compatibility-date.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -657,4 +657,12 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
#
# This is a compat flag so that we can opt in our test workers into it before rolling out to
# everyone.

noTopLevelAwaitInRequire @67 :Bool
$compatEnableFlag("disable_top_level_await_in_require")
$compatDisableFlag("enable_top_level_await_in_require")
$compatEnableDate("2024-12-02");
# When enabled, use of top-level await syntax in require() calls will be disallowed.
# The ecosystem and runtimes are moving to a state where top level await in modules
# is being strongly discouraged.
}
3 changes: 3 additions & 0 deletions src/workerd/io/worker.c++
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,9 @@ Worker::Isolate::Isolate(kj::Own<Api> apiParam,
if (features.getNodeJsCompatV2()) {
lock->setNodeJsCompatEnabled();
}
if (features.getNoTopLevelAwaitInRequire()) {
lock->disableTopLevelAwait();
}

if (impl->inspector != kj::none || ::kj::_::Debug::shouldLog(::kj::LogSeverity::INFO)) {
lock->setLoggerCallback([this](jsg::Lock& js, kj::StringPtr message) {
Expand Down
4 changes: 4 additions & 0 deletions src/workerd/jsg/jsg.c++
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ void Lock::setNodeJsCompatEnabled() {
IsolateBase::from(v8Isolate).setNodeJsCompatEnabled({}, true);
}

void Lock::disableTopLevelAwait() {
IsolateBase::from(v8Isolate).disableTopLevelAwait();
}

void Lock::setToStringTag() {
IsolateBase::from(v8Isolate).enableSetToStringTag();
}
Expand Down
1 change: 1 addition & 0 deletions src/workerd/jsg/jsg.h
Original file line number Diff line number Diff line change
Expand Up @@ -2516,6 +2516,7 @@ class Lock {

void setNodeJsCompatEnabled();
void setToStringTag();
void disableTopLevelAwait();

using Logger = void(Lock&, kj::StringPtr);
void setLoggerCallback(kj::Function<Logger>&& logger);
Expand Down
168 changes: 108 additions & 60 deletions src/workerd/jsg/modules.c++
Original file line number Diff line number Diff line change
Expand Up @@ -272,34 +272,12 @@ v8::Local<v8::Value> CommonJsModuleContext::require(jsg::Lock& js, kj::String sp
// Adding imported from suffix here not necessary like it is for resolveCallback, since we have a
// js stack that will include the parent module's name and location of the failed require().

JSG_REQUIRE_NONNULL(
info.maybeSynthetic, TypeError, "Cannot use require() to import an ES Module.");

auto module = info.module.getHandle(js);

check(module->InstantiateModule(js.v8Context(), &resolveCallback));
auto handle = check(module->Evaluate(js.v8Context()));
KJ_ASSERT(handle->IsPromise());
auto prom = handle.As<v8::Promise>();

// This assert should always pass since evaluateSyntheticModuleCallback() for CommonJS
// modules (below) always returns an already-resolved promise.
KJ_ASSERT(prom->State() != v8::Promise::PromiseState::kPending);

if (module->GetStatus() == v8::Module::kErrored) {
throwTunneledException(js.v8Isolate, module->GetException());
}

// Originally, This returned an object like `{default: module.exports}` when we really
// intended to return the module exports raw. We should be extracting `default` here.
// Unfortunately, there is a user depending on the wrong behavior in production, so we
// needed a compatibility flag to fix.
ModuleRegistry::RequireImplOptions options = ModuleRegistry::RequireImplOptions::DEFAULT;
if (getCommonJsExportDefault(js.v8Isolate)) {
return check(module->GetModuleNamespace().As<v8::Object>()->Get(
js.v8Context(), v8StrIntern(js.v8Isolate, "default")));
} else {
return module->GetModuleNamespace();
options = ModuleRegistry::RequireImplOptions::EXPORT_DEFAULT;
}

return ModuleRegistry::requireImpl(js, info, options);
}

v8::Local<v8::Value> NonModuleScript::runAndReturn(v8::Local<v8::Context> context) const {
Expand All @@ -322,32 +300,43 @@ NonModuleScript NonModuleScript::compile(kj::StringPtr code, jsg::Lock& js, kj::
return NonModuleScript(js, check(v8::ScriptCompiler::CompileUnboundScript(isolate, &source)));
}

void instantiateModule(jsg::Lock& js, v8::Local<v8::Module>& module) {
void instantiateModule(
jsg::Lock& js, v8::Local<v8::Module>& module, InstantiateModuleOptions options) {
KJ_ASSERT(!module.IsEmpty());
auto isolate = js.v8Isolate;
auto context = js.v8Context();

auto status = module->GetStatus();
// Nothing to do if the module is already instantiated, evaluated, or errored.
if (status == v8::Module::Status::kInstantiated || status == v8::Module::Status::kEvaluated ||
status == v8::Module::Status::kErrored)
return;

JSG_REQUIRE(status == v8::Module::Status::kUninstantiated, Error,
"Module cannot be synchronously required while it is being instantiated or evaluated. "
"This error typically means that a CommonJS or NodeJS-Compat type module has a circular "
"dependency on itself, and that a synchronous require() is being called while the module "
"is being loaded.");

jsg::check(module->InstantiateModule(context, &resolveCallback));

// If the previous instantiation failed, throw the exception.
if (status == v8::Module::Status::kErrored) {
isolate->ThrowException(module->GetException());
throw jsg::JsExceptionThrown();
}

// Nothing to do if the module is already evaluated.
if (status == v8::Module::Status::kEvaluated || status == v8::Module::Status::kEvaluating) return;

if (status == v8::Module::Status::kUninstantiated) {
jsg::check(module->InstantiateModule(context, &resolveCallback));
}

auto prom = jsg::check(module->Evaluate(context)).As<v8::Promise>();

if (module->IsGraphAsync() && prom->State() == v8::Promise::kPending) {
// If top level await has been disable, error.
JSG_REQUIRE(options != InstantiateModuleOptions::NO_TOP_LEVEL_AWAIT, Error,
"Top-level await in module is not permitted at this time.");
}
// We run microtasks to ensure that any promises that happen to be scheduled
// during the evaluation of the top level scope have a chance to be settled,
// even if those are not directly awaited.
js.runMicrotasks();

switch (prom->State()) {
case v8::Promise::kPending:
// Let's make sure nobody is depending on pending modules that do not resolve first.
KJ_LOG(WARNING, "Async module was not immediately resolved.");
break;
// Let's make sure nobody is depending on modules awaiting on pending promises.
JSG_FAIL_REQUIRE(Error, "Top-level await in module is unsettled.");
case v8::Promise::kRejected:
// Since we don't actually support I/O when instantiating a worker, we don't return the
// promise from module->Evaluate, which means we lose any errors that happen during
Expand Down Expand Up @@ -484,7 +473,7 @@ v8::Local<v8::WasmModuleObject> compileWasmModule(

// ======================================================================================

jsg::Ref<jsg::Object> ModuleRegistry::NodeJsModuleInfo::initModuleContext(
jsg::Ref<NodeJsModuleContext> ModuleRegistry::NodeJsModuleInfo::initModuleContext(
jsg::Lock& js, kj::StringPtr name) {
return jsg::alloc<NodeJsModuleContext>(js, kj::Path::parse(name));
}
Expand Down Expand Up @@ -592,23 +581,7 @@ v8::Local<v8::Value> NodeJsModuleContext::require(jsg::Lock& js, kj::String spec
info.maybeSynthetic, TypeError, "Cannot use require() to import an ES Module.");
}

auto module = info.module.getHandle(js);

jsg::instantiateModule(js, module);

auto handle = jsg::check(module->Evaluate(js.v8Context()));
KJ_ASSERT(handle->IsPromise());
auto prom = handle.As<v8::Promise>();

// This assert should always pass since evaluateSyntheticModuleCallback() for CommonJS
// modules (below) always returns an already-resolved promise.
KJ_ASSERT(prom->State() != v8::Promise::PromiseState::kPending);

if (module->GetStatus() == v8::Module::kErrored) {
jsg::throwTunneledException(js.v8Isolate, module->GetException());
}

return js.v8Get(module->GetModuleNamespace().As<v8::Object>(), "default"_kj);
return ModuleRegistry::requireImpl(js, info, ModuleRegistry::RequireImplOptions::EXPORT_DEFAULT);
}

v8::Local<v8::Value> NodeJsModuleContext::getBuffer(jsg::Lock& js) {
Expand Down Expand Up @@ -679,4 +652,79 @@ kj::Maybe<kj::OneOf<kj::String, ModuleRegistry::ModuleInfo>> tryResolveFromFallb
return kj::none;
}

JsValue ModuleRegistry::requireImpl(Lock& js, ModuleInfo& info, RequireImplOptions options) {
auto module = info.module.getHandle(js);

// If the module status is evaluating or instantiating then the module is likely
// has a circular dependency on itself. If the module is a CommonJS or NodeJS
// module, we can return the exports object directly here.
if (module->GetStatus() == v8::Module::Status::kEvaluating ||
module->GetStatus() == v8::Module::Status::kInstantiating) {
KJ_IF_SOME(synth, info.maybeSynthetic) {
KJ_IF_SOME(cjs, synth.tryGet<ModuleRegistry::CommonJsModuleInfo>()) {
return JsValue(cjs.moduleContext->getExports(js));
}
KJ_IF_SOME(cjs, synth.tryGet<ModuleRegistry::NodeJsModuleInfo>()) {
return JsValue(cjs.moduleContext->getExports(js));
}
}
}

// When using require(...) we previously allowed the required modules to use
// top-level await. With a compat flag we disable use of top-level await but
// ONLY when the module is synchronously required. The same module being imported
// either statically or dynamically can still use TLA. This aligns with behavior
// being implemented in other JS runtimes.
auto& isolateBase = IsolateBase::from(js.v8Isolate);
jsg::InstantiateModuleOptions opts = jsg::InstantiateModuleOptions::DEFAULT;
if (!isolateBase.isTopLevelAwaitEnabled()) {
opts = jsg::InstantiateModuleOptions::NO_TOP_LEVEL_AWAIT;

// If the module was already evaluated, let's check if it is async.
// If it is, we will throw an error. This case can happen if a previous
// attempt to require the module failed because the module was async.
if (module->GetStatus() == v8::Module::kEvaluated) {
JSG_REQUIRE(!module->IsGraphAsync(), Error,
"Top-level await in module is not permitted at this time.");
}
}

jsg::instantiateModule(js, module, opts);

if (info.maybeSynthetic == kj::none) {
// If the module is an ESM and the __cjsUnwrapDefault flag is set to true, we will
// always return the default export regardless of the options.
// Otherwise fallback to the options. This is an early version of the "module.exports"
// convention that Node.js finally adopted for require(esm) that was not officially
// adopted but there are a handful of modules in the ecosystem that supported it
// early. It's trivial for us to support here so let's just do so.
JsObject obj(module->GetModuleNamespace().As<v8::Object>());
if (obj.get(js, "__cjsUnwrapDefault"_kj) == js.boolean(true)) {
return obj.get(js, "default"_kj);
}
// If the ES Module namespace exports a "module.exports" key then that will be the
// export that is returned by the require(...) call per Node.js' recently added
// require(esm) support.
// See: https://nodejs.org/docs/latest/api/modules.html#loading-ecmascript-modules-using-require
if (obj.has(js, "module.exports"_kj)) {
// We only want to return the value if it is explicitly specified, otherwise we'll
// always be returning undefined.
return obj.get(js, "module.exports"_kj);
}
}

// Originally, require returned an object like `{default: module.exports}` when we really
// intended to return the module exports raw. We should be extracting `default` here.
// When Node.js recently finally adopted require(esm), they adopted the default behavior
// of exporting the module namespace, which is fun. We'll stick with our default here for
// now but users can get Node.js-like behavior by switching off the
// exportCommonJsDefaultNamespace compat flag.
if (options == RequireImplOptions::EXPORT_DEFAULT) {
return JsValue(check(module->GetModuleNamespace().As<v8::Object>()->Get(
js.v8Context(), v8StrIntern(js.v8Isolate, "default"))));
}

return JsValue(module->GetModuleNamespace());
}

} // namespace workerd::jsg
Loading

0 comments on commit 12479a1

Please sign in to comment.