Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions src/workerd/io/compatibility-date.capnp
Original file line number Diff line number Diff line change
Expand Up @@ -1316,4 +1316,14 @@ struct CompatibilityFlags @0x8f8c1b68151b6cef {
# Node.js-compatible versions from node:timers. setTimeout and setInterval return
# Timeout objects with methods like refresh(), ref(), unref(), and hasRef().
# This flag requires nodejs_compat or nodejs_compat_v2 to be enabled.

useNullPrototypeForOpaqueWrappers @154 :Bool
$compatEnableFlag("use_null_prototype_for_opaque_wrappers")
$compatDisableFlag("use_object_prototype_for_opaque_wrappers")
$compatEnableDate("2026-01-27");
# When enabled, internal opaque wrapper objects (used to pass C++ values through V8 promises)
# have a null prototype instead of inheriting from Object.prototype. This prevents
# Object.prototype.then patches from intercepting internal promise operations, which is
# required for WPT compliance. The streams spec requires that piping operations are not
# observable through Object.prototype.then interception.
}
3 changes: 3 additions & 0 deletions src/workerd/io/worker.c++
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,9 @@ Worker::Isolate::Isolate(kj::Own<Api> apiParam,
if (features.getFastJsgStruct()) {
lock->setUsingFastJsgStruct();
}
if (features.getUseNullPrototypeForOpaqueWrappers()) {
lock->setNullPrototypeForOpaqueWrappers();
}

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 @@ -239,6 +239,10 @@ void Lock::setImmutablePrototype() {
IsolateBase::from(v8Isolate).enableSetImmutablePrototype();
}

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

void Lock::setLoggerCallback(kj::Function<Logger>&& logger) {
IsolateBase::from(v8Isolate).setLoggerCallback({}, kj::mv(logger));
}
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 @@ -2651,6 +2651,7 @@ class Lock {
bool getThrowOnUnrecognizedImportAssertion() const;
void setToStringTag();
void setImmutablePrototype();
void setNullPrototypeForOpaqueWrappers();
void disableTopLevelAwait();

using Logger = void(Lock&, kj::StringPtr);
Expand Down
34 changes: 20 additions & 14 deletions src/workerd/jsg/setup.c++
Original file line number Diff line number Diff line change
Expand Up @@ -424,18 +424,6 @@ IsolateBase::IsolateBase(V8System& system,
});

ptr->GetHeapProfiler()->AddBuildEmbedderGraphCallback(buildEmbedderGraph, this);

{
// We don't need a v8::Locker here since there's no way another thread could be using the
// isolate yet, but we do need v8::Isolate::Scope.
v8::Isolate::Scope isolateScope(ptr);
v8::HandleScope scope(ptr);

// Create opaqueTemplate
auto opaqueTemplate = v8::FunctionTemplate::New(ptr, &throwIllegalConstructor);
opaqueTemplate->InstanceTemplate()->SetInternalFieldCount(Wrappable::INTERNAL_FIELD_COUNT);
this->opaqueTemplate.Reset(ptr, opaqueTemplate);
}
});
}

Expand All @@ -455,8 +443,26 @@ IsolateBase::~IsolateBase() noexcept(false) {
}

v8::Local<v8::FunctionTemplate> IsolateBase::getOpaqueTemplate(v8::Isolate* isolate) {
return static_cast<IsolateBase*>(isolate->GetData(SET_DATA_ISOLATE_BASE))
->opaqueTemplate.Get(isolate);
auto& self = *static_cast<IsolateBase*>(isolate->GetData(SET_DATA_ISOLATE_BASE));

// Lazily create the template on first use so the flag is already set.
if (self.opaqueTemplate.IsEmpty()) {
v8::HandleScope scope(isolate);
auto tmpl = v8::FunctionTemplate::New(isolate, &throwIllegalConstructor);
tmpl->InstanceTemplate()->SetInternalFieldCount(Wrappable::INTERNAL_FIELD_COUNT);

if (self.shouldUseNullPrototypeForOpaqueWrappers()) {
// Use null prototype to prevent Object.prototype.then patches from intercepting
// internal promise operations (required for WPT compliance).
auto protoProvider = v8::FunctionTemplate::New(isolate, &throwIllegalConstructor);
protoProvider->RemovePrototype();
tmpl->SetPrototypeProviderTemplate(protoProvider);
}

self.opaqueTemplate.Reset(isolate, tmpl);
}

return self.opaqueTemplate.Get(isolate);
}

void IsolateBase::dropWrappers(kj::FunctionParam<void()> drop) {
Expand Down
9 changes: 9 additions & 0 deletions src/workerd/jsg/setup.h
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,14 @@ class IsolateBase {
shouldSetImmutablePrototypeFlag = true;
}

inline bool shouldUseNullPrototypeForOpaqueWrappers() const {
return useNullPrototypeForOpaqueWrappersFlag;
}

void enableNullPrototypeForOpaqueWrappers() {
useNullPrototypeForOpaqueWrappersFlag = true;
}

inline void disableTopLevelAwait() {
allowTopLevelAwait = false;
}
Expand Down Expand Up @@ -341,6 +349,7 @@ class IsolateBase {
bool nodeJsProcessV2Enabled = false;
bool setToStringTag = false;
bool shouldSetImmutablePrototypeFlag = false;
bool useNullPrototypeForOpaqueWrappersFlag = false;
bool allowTopLevelAwait = true;
bool usingNewModuleRegistry = false;
bool usingEnhancedErrorSerialization = false;
Expand Down
10 changes: 8 additions & 2 deletions src/wpt/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ wpt_test(
wpt_test(
name = "fetch/api",
size = "large",
compat_flags = ["strip_bom_in_read_all_text"],
compat_flags = [
"strip_bom_in_read_all_text",
"use_null_prototype_for_opaque_wrappers",
],
config = "fetch/api-test.ts",
start_server = True,
target_compatible_with = select({
Expand Down Expand Up @@ -111,7 +114,10 @@ wpt_test(
wpt_test(
name = "streams",
size = "large",
compat_flags = ["pedantic_wpt"],
compat_flags = [
"use_null_prototype_for_opaque_wrappers",
"pedantic_wpt",
],
config = "streams-test.ts",
wpt_directory = "@wpt//:streams@module",
)
Expand Down
11 changes: 1 addition & 10 deletions src/wpt/fetch/api-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,14 +842,5 @@ export default {
'response/response-stream-disturbed-6.any.js': {},
'response/response-stream-disturbed-by-pipe.any.js': {},
'response/response-stream-disturbed-util.js': {},
'response/response-stream-with-broken-then.any.js': {
comment:
'Triggers an internal error: promise.h:103: failed: expected Wrappable::tryUnwrapOpaque(isolate, handle) != nullptr',
expectedFailures: [
'Attempt to inject {done: false, value: bye} via Object.prototype.then.',
'Attempt to inject value: undefined via Object.prototype.then.',
'Attempt to inject undefined via Object.prototype.then.',
'Attempt to inject 8.2 via Object.prototype.then.',
],
},
'response/response-stream-with-broken-then.any.js': {},
} satisfies TestRunnerConfig;
9 changes: 1 addition & 8 deletions src/wpt/streams-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,7 @@ export default {
? ['pipeThrough() should throw if readable/writable getters throw']
: [],
},
'piping/then-interception.any.js': {
comment:
'failed: expected Wrappable::tryUnwrapOpaque(isolate, handle) != nullptr',
expectedFailures: [
'piping should not be observable',
'tee should not be observable',
],
},
'piping/then-interception.any.js': {},
'piping/throwing-options.any.js': {},
'piping/transform-streams.any.js': {},

Expand Down
Loading