diff --git a/src/workerd/api/global-scope.c++ b/src/workerd/api/global-scope.c++ index 005d48e058f..7446c22b171 100644 --- a/src/workerd/api/global-scope.c++ +++ b/src/workerd/api/global-scope.c++ @@ -918,4 +918,40 @@ void ServiceWorkerGlobalScope::clearImmediate(kj::Maybe> may } } +jsg::JsObject Cloudflare::getCompatibilityFlags(jsg::Lock& js) { + auto flags = FeatureFlags::get(js); + auto obj = js.objNoProto(); + auto dynamic = capnp::toDynamic(flags); + auto schema = dynamic.getSchema(); + + bool skipExperimental = !flags.getWorkerdExperimental(); + + for (auto field: schema.getFields()) { + // If this is an experimental flag, we expose it only if the experimental mode + // is enabled. + auto annotations = field.getProto().getAnnotations(); + bool skip = false; + if (skipExperimental) { + for (auto annotation: annotations) { + if (annotation.getId() == EXPERIMENTAl_ANNOTATION_ID) { + skip = true; + break; + } + } + } + if (skip) continue; + + // Note that disable flags are not exposed. + for (auto annotation: annotations) { + if (annotation.getId() == COMPAT_ENABLE_FLAG_ANNOTATION_ID) { + obj.setReadOnly( + js, annotation.getValue().getText(), js.boolean(dynamic.get(field).as())); + } + } + } + + obj.seal(js); + return obj; +} + } // namespace workerd::api diff --git a/src/workerd/api/global-scope.h b/src/workerd/api/global-scope.h index 46b5a1aff1a..c14f169ce71 100644 --- a/src/workerd/api/global-scope.h +++ b/src/workerd/api/global-scope.h @@ -110,6 +110,22 @@ class Performance: public jsg::Object { } }; +// Exposed as a global to provide access to certain Cloudflare-specific +// configuration details. This is not a standard API and great care should +// be taken when deciding to expose new properties or methods here. +class Cloudflare: public jsg::Object { +public: + // Return an object containing the state of all compatibility flags known to the runtime. + jsg::JsObject getCompatibilityFlags(jsg::Lock& js); + + JSG_RESOURCE_TYPE(Cloudflare) { + JSG_LAZY_READONLY_INSTANCE_PROPERTY(compatibilityFlags, getCompatibilityFlags); + + JSG_TS_OVERRIDE({ readonly compatibilityFlags: Record; + }); + } +}; + class PromiseRejectionEvent: public Event { public: PromiseRejectionEvent( @@ -507,6 +523,10 @@ class ServiceWorkerGlobalScope: public WorkerGlobalScope { return jsg::alloc(); } + jsg::Ref getCloudflare() { + return jsg::alloc(); + } + // The origin is unknown, return "null" as described in // https://html.spec.whatwg.org/multipage/browsers.html#concept-origin-opaque. kj::StringPtr getOrigin() { @@ -570,6 +590,7 @@ class ServiceWorkerGlobalScope: public WorkerGlobalScope { JSG_LAZY_INSTANCE_PROPERTY(caches, getCaches); JSG_LAZY_INSTANCE_PROPERTY(scheduler, getScheduler); JSG_LAZY_INSTANCE_PROPERTY(performance, getPerformance); + JSG_LAZY_INSTANCE_PROPERTY(Cloudflare, getCloudflare); JSG_READONLY_INSTANCE_PROPERTY(origin, getOrigin); JSG_NESTED_TYPE(Event); @@ -824,6 +845,6 @@ class ServiceWorkerGlobalScope: public WorkerGlobalScope { api::WorkerGlobalScope, api::ServiceWorkerGlobalScope, api::TestController, \ api::ExecutionContext, api::ExportedHandler, \ api::ServiceWorkerGlobalScope::StructuredCloneOptions, api::PromiseRejectionEvent, \ - api::Navigator, api::Performance, api::AlarmInvocationInfo, api::Immediate + api::Navigator, api::Performance, api::AlarmInvocationInfo, api::Immediate, api::Cloudflare // The list of global-scope.h types that are added to worker.c++'s JSG_DECLARE_ISOLATE_TYPE } // namespace workerd::api diff --git a/src/workerd/api/node/node.h b/src/workerd/api/node/node.h index 375ddde26ae..9afa5f170a5 100644 --- a/src/workerd/api/node/node.h +++ b/src/workerd/api/node/node.h @@ -12,6 +12,7 @@ #include #include #include +#include namespace workerd::api::node { diff --git a/src/workerd/api/tests/compat-flags-test.js b/src/workerd/api/tests/compat-flags-test.js new file mode 100644 index 00000000000..23f47efd635 --- /dev/null +++ b/src/workerd/api/tests/compat-flags-test.js @@ -0,0 +1,60 @@ +import { + ok, + strictEqual, + throws, +} from 'node:assert'; + +const { compatibilityFlags } = globalThis.Cloudflare; +ok(Object.isSealed(compatibilityFlags)); +ok(Object.isFrozen(compatibilityFlags)); +ok(!Object.isExtensible(compatibilityFlags)); + +// It shuld be possible to shadow the Cloudflare global +const Cloudflare = 1; +strictEqual(Cloudflare, 1); + +export const compatFlagsTest = { + test() { + // The compatibility flags object should be sealed, frozen, and not extensible. + throws(() => compatibilityFlags.no_nodejs_compat_v2 = "..."); + throws(() => compatibilityFlags.not_a_real_compat_flag = "..."); + throws(() => { delete compatibilityFlags['nodejs_compat_v2']; }); + + // The compatibility flags object should have no prototype. + strictEqual(Object.getPrototypeOf(compatibilityFlags), null); + + // The compatibility flags object should have the expected properties... + // That is... the only keys that should appear on the compatibilityFlags + // object are the enable flags. + // + // If a key does not appear, it can mean one of three things: + // 1. It is a disable flag. + // 2. It is an experimental flag and the experimental option is not set. + // 3. The flag does not exist. + // + // At this level we make no attempt to differentiate between these cases + ok(compatibilityFlags['nodejs_compat_v2']); + ok(compatibilityFlags['url_standard']); + + ok(!compatibilityFlags['no_nodejs_compat_v2']); + ok(!compatibilityFlags['url_original']); + strictEqual(compatibilityFlags['no_nodejs_compat_v2'], undefined); + strictEqual(compatibilityFlags['url_original'], undefined); + + // Since we are not specifying the experimental flag, experimental flags should + // not be included in the output. + strictEqual(compatibilityFlags['durable_object_rename'], undefined); + strictEqual('durable_object_rename' in compatibilityFlags, false); + + // If a flag does not exist, the value will be undefined. + strictEqual(compatibilityFlags['not-a-real-compat-flag'], undefined); + strictEqual('not-a-real-compat-flag' in compatibilityFlags, false); + + // The compatibility flags object should have the expected keys. + const keys = Object.keys(compatibilityFlags); + ok(keys.includes('nodejs_compat_v2')); + ok(keys.includes('url_standard')); + ok(!keys.includes('url_original')); + ok(!keys.includes('not-a-real-compat-flag')); + } +} diff --git a/src/workerd/api/tests/compat-flags-test.wd-test b/src/workerd/api/tests/compat-flags-test.wd-test new file mode 100644 index 00000000000..38d8c5c1e32 --- /dev/null +++ b/src/workerd/api/tests/compat-flags-test.wd-test @@ -0,0 +1,15 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const unitTests :Workerd.Config = ( + services = [ + ( name = "compat-flags-test", + worker = ( + modules = [ + (name = "worker", esModule = embed "compat-flags-test.js") + ], + compatibilityDate = "2023-01-15", + compatibilityFlags = ["nodejs_compat_v2"], + ) + ), + ], +); diff --git a/src/workerd/io/features.h b/src/workerd/io/features.h index 28e0fb62809..2a00be1c4b8 100644 --- a/src/workerd/io/features.h +++ b/src/workerd/io/features.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include namespace workerd { diff --git a/src/workerd/jsg/jsg.h b/src/workerd/jsg/jsg.h index cd21e220202..95a5d7b4af7 100644 --- a/src/workerd/jsg/jsg.h +++ b/src/workerd/jsg/jsg.h @@ -2541,6 +2541,7 @@ class Lock { JsSymbol symbolShared(kj::StringPtr) KJ_WARN_UNUSED_RESULT; JsSymbol symbolInternal(kj::StringPtr) KJ_WARN_UNUSED_RESULT; JsObject obj() KJ_WARN_UNUSED_RESULT; + JsObject objNoProto() KJ_WARN_UNUSED_RESULT; JsMap map() KJ_WARN_UNUSED_RESULT; JsValue external(void*) KJ_WARN_UNUSED_RESULT; JsValue error(kj::StringPtr message) KJ_WARN_UNUSED_RESULT; diff --git a/src/workerd/jsg/jsvalue.c++ b/src/workerd/jsg/jsvalue.c++ index 7fe936a108d..6fdd9e0793a 100644 --- a/src/workerd/jsg/jsvalue.c++ +++ b/src/workerd/jsg/jsvalue.c++ @@ -60,6 +60,12 @@ void JsObject::set(Lock& js, kj::StringPtr name, const JsValue& value) { set(js, js.strIntern(name), value); } +void JsObject::setReadOnly(Lock& js, kj::StringPtr name, const JsValue& value) { + v8::Local nameStr = js.strIntern(name); + check(inner->DefineOwnProperty(js.v8Context(), nameStr, value, + static_cast(v8::ReadOnly | v8::DontDelete))); +} + JsValue JsObject::get(Lock& js, const JsValue& name) { return JsValue(check(inner->Get(js.v8Context(), name.inner))); } @@ -135,6 +141,10 @@ void JsObject::recursivelyFreeze(Lock& js) { jsg::recursivelyFreeze(js.v8Context(), inner); } +void JsObject::seal(Lock& js) { + check(inner->SetIntegrityLevel(js.v8Context(), v8::IntegrityLevel::kSealed)); +} + JsObject JsObject::jsonClone(Lock& js) { auto tmp = JsValue(inner).toJson(js); auto obj = KJ_ASSERT_NONNULL(JsValue::fromJson(js, tmp).tryCast()); @@ -421,6 +431,10 @@ JsObject Lock::obj() { return JsObject(v8::Object::New(v8Isolate)); } +JsObject Lock::objNoProto() { + return JsObject(v8::Object::New(v8Isolate, v8::Null(v8Isolate), nullptr, nullptr, 0)); +} + JsMap Lock::map() { return JsMap(v8::Map::New(v8Isolate)); } diff --git a/src/workerd/jsg/jsvalue.h b/src/workerd/jsg/jsvalue.h index 5a9d56ea884..a04ccf1c804 100644 --- a/src/workerd/jsg/jsvalue.h +++ b/src/workerd/jsg/jsvalue.h @@ -341,6 +341,7 @@ class JsObject final: public JsBase { void set(Lock& js, const JsValue& name, const JsValue& value); void set(Lock& js, kj::StringPtr name, const JsValue& value); + void setReadOnly(Lock& js, kj::StringPtr name, const JsValue& value); JsValue get(Lock& js, const JsValue& name) KJ_WARN_UNUSED_RESULT; JsValue get(Lock& js, kj::StringPtr name) KJ_WARN_UNUSED_RESULT; @@ -377,6 +378,7 @@ class JsObject final: public JsBase { using JsBase::JsBase; void recursivelyFreeze(Lock&); + void seal(Lock&); JsObject jsonClone(Lock&); };