diff --git a/0001-Implements-cloudflare-compatibility-flags-API.patch b/0001-Implements-cloudflare-compatibility-flags-API.patch new file mode 100644 index 000000000000..06d40b360061 --- /dev/null +++ b/0001-Implements-cloudflare-compatibility-flags-API.patch @@ -0,0 +1,161 @@ +From fdac25bc6ad66aed94bf7e89e0f2afc1158c32d8 Mon Sep 17 00:00:00 2001 +From: James M Snell +Date: Wed, 12 Jun 2024 12:52:43 -0700 +Subject: [PATCH] Implements cloudflare:compatibility-flags API + +A simple built-in module and API for determining if a given compat +flag is set. + +``` +import { compatFlags } from 'cloudflare:workers' + +console.log(compatFlags['url_standard']); +console.log(compatFlags['url_original']); +``` +--- + src/cloudflare/workers.ts | 6 +++++ + src/workerd/api/node/node.h | 22 ++++++++++++++++++ + src/workerd/api/tests/compat-flags-test.js | 23 +++++++++++++++++++ + .../api/tests/compat-flags-test.wd-test | 15 ++++++++++++ + src/workerd/jsg/jsvalue.c++ | 5 ++++ + src/workerd/jsg/jsvalue.h | 1 + + 6 files changed, 72 insertions(+) + create mode 100644 src/workerd/api/tests/compat-flags-test.js + create mode 100644 src/workerd/api/tests/compat-flags-test.wd-test + +diff --git a/src/cloudflare/workers.ts b/src/cloudflare/workers.ts +index 2fd18587..9d30e321 100644 +--- a/src/cloudflare/workers.ts ++++ b/src/cloudflare/workers.ts +@@ -11,3 +11,9 @@ export const WorkerEntrypoint = entrypoints.WorkerEntrypoint; + export const DurableObject = entrypoints.DurableObject; + export const RpcStub = entrypoints.RpcStub; + export const RpcTarget = entrypoints.RpcTarget; ++ ++/* eslint-disable */ ++import { default as flags } from 'workerd:compatibility-flags'; ++export const compatFlags = (flags as any).compatFlags; ++Object.freeze(compatFlags); ++/* eslint-enable */ +diff --git a/src/workerd/api/node/node.h b/src/workerd/api/node/node.h +index d24b800c..39c7a39c 100644 +--- a/src/workerd/api/node/node.h ++++ b/src/workerd/api/node/node.h +@@ -9,6 +9,7 @@ + #include + #include + #include ++#include + + namespace workerd::api::node { + +@@ -17,7 +18,28 @@ namespace workerd::api::node { + // built-ins + class CompatibilityFlags : public jsg::Object { + public: ++ ++ jsg::JsObject getCompatFlags(jsg::Lock& js, workerd::CompatibilityFlags::Reader flags) { ++ auto obj = js.obj(); ++ auto dynamic = capnp::toDynamic(flags); ++ auto schema = dynamic.getSchema(); ++ for (auto field : schema.getFields()) { ++ bool value = dynamic.get(field).as(); ++ for (auto annotation : field.getProto().getAnnotations()) { ++ if (annotation.getId() == COMPAT_ENABLE_FLAG_ANNOTATION_ID) { ++ obj.setReadOnly(js, annotation.getValue().getText(), js.boolean(value)); ++ } ++ else if (annotation.getId() == COMPAT_DISABLE_FLAG_ANNOTATION_ID) { ++ obj.setReadOnly(js, annotation.getValue().getText(), js.boolean(!value)); ++ } ++ } ++ } ++ return obj; ++ } ++ + JSG_RESOURCE_TYPE(CompatibilityFlags, workerd::CompatibilityFlags::Reader flags) { ++ JSG_LAZY_READONLY_INSTANCE_PROPERTY(compatFlags, getCompatFlags); ++ + // Not your typical JSG_RESOURCE_TYPE definition.. here we are iterating + // through all of the compatibility flags and registering each as read-only + // literal values on the instance... +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 00000000..f27bff8a +--- /dev/null ++++ b/src/workerd/api/tests/compat-flags-test.js +@@ -0,0 +1,23 @@ ++import { ++ ok, ++ throws, ++} from 'node:assert'; ++ ++import { compatFlags } from 'cloudflare:workers'; ++ ++export const compatFlagsTest = { ++ test() { ++ throws(() => compatFlags.no_nodejs_compat_v2 = true); ++ throws(() => compatFlags.not_a_real_compat_flag = true); ++ ok(compatFlags['nodejs_compat_v2']); ++ ok(!compatFlags['no_nodejs_compat_v2']); ++ ok(compatFlags['url_standard']); ++ ok(!compatFlags['url_original']); ++ ok(!compatFlags['not-a-real-compat-flag']); ++ const keys = Object.keys(compatFlags); ++ 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 00000000..38d8c5c1 +--- /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/jsg/jsvalue.c++ b/src/workerd/jsg/jsvalue.c++ +index 933fa28b..24cce4e7 100644 +--- a/src/workerd/jsg/jsvalue.c++ ++++ b/src/workerd/jsg/jsvalue.c++ +@@ -58,6 +58,11 @@ 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, v8::ReadOnly)); ++} ++ + JsValue JsObject::get(Lock& js, const JsValue& name) { + return JsValue(check(inner->Get(js.v8Context(), name.inner))); + } +diff --git a/src/workerd/jsg/jsvalue.h b/src/workerd/jsg/jsvalue.h +index ea4c9c30..343dcc07 100644 +--- a/src/workerd/jsg/jsvalue.h ++++ b/src/workerd/jsg/jsvalue.h +@@ -329,6 +329,7 @@ public: + + 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; + +-- +2.34.1 + diff --git a/src/cloudflare/workers.ts b/src/cloudflare/workers.ts index 2fd18587a1f2..33f4c9da1d52 100644 --- a/src/cloudflare/workers.ts +++ b/src/cloudflare/workers.ts @@ -11,3 +11,8 @@ export const WorkerEntrypoint = entrypoints.WorkerEntrypoint; export const DurableObject = entrypoints.DurableObject; export const RpcStub = entrypoints.RpcStub; export const RpcTarget = entrypoints.RpcTarget; + +/* eslint-disable */ +import { default as flags } from 'workerd:compatibility-flags'; +export const compatibilityFlags = (flags as any).compatibilityFlags; +/* eslint-enable */ diff --git a/src/workerd/api/node/node.h b/src/workerd/api/node/node.h index 78bc24114ff9..e31101c72605 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 { @@ -23,7 +24,51 @@ class CompatibilityFlags : public jsg::Object { CompatibilityFlags() = default; CompatibilityFlags(jsg::Lock&, const jsg::Url&) {} + // The compatibility flags method here is called at most once per isolate realm. + // After that, the flags are cached and always readonly. + jsg::JsObject getCompatibilityFlags(jsg::Lock& js, workerd::CompatibilityFlags::Reader flags) { + auto obj = js.objNoProto(); + auto dynamic = capnp::toDynamic(flags); + auto schema = dynamic.getSchema(); + + auto on = js.strIntern("on"); + auto off = js.strIntern("off"); + 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; + + bool value = dynamic.get(field).as(); + for (auto annotation : annotations) { + if (annotation.getId() == COMPAT_ENABLE_FLAG_ANNOTATION_ID) { + obj.setReadOnly(js, annotation.getValue().getText(), value ? on : off); + } + else if (annotation.getId() == COMPAT_DISABLE_FLAG_ANNOTATION_ID) { + // Take note of the inverted ternary here... above it is on : off, here it is off : on + obj.setReadOnly(js, annotation.getValue().getText(), value ? off : on); + } + } + } + + obj.seal(js); + return obj; + } + JSG_RESOURCE_TYPE(CompatibilityFlags, workerd::CompatibilityFlags::Reader flags) { + JSG_LAZY_READONLY_INSTANCE_PROPERTY(compatibilityFlags, getCompatibilityFlags); + // Not your typical JSG_RESOURCE_TYPE definition.. here we are iterating // through all of the compatibility flags and registering each as read-only // literal values on the instance... 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 000000000000..1da8eee7b3e3 --- /dev/null +++ b/src/workerd/api/tests/compat-flags-test.js @@ -0,0 +1,38 @@ +import { + ok, + strictEqual, + throws, +} from 'node:assert'; + +import { compatibilityFlags } from 'cloudflare:workers'; + +const kOn = 'on'; +const kOff = 'off'; + +export const compatFlagsTest = { + test() { + throws(() => compatibilityFlags.no_nodejs_compat_v2 = "..."); + throws(() => compatibilityFlags.not_a_real_compat_flag = "..."); + throws(() => { delete compatibilityFlags['nodejs_compat_v2']; }); + + strictEqual(compatibilityFlags['nodejs_compat_v2'], kOn); + strictEqual(compatibilityFlags['no_nodejs_compat_v2'], kOff); + strictEqual(compatibilityFlags['url_standard'], kOn); + strictEqual(compatibilityFlags['url_original'], kOff); + + // 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); + + // Importantly, 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); + + 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 000000000000..38d8c5c1e32c --- /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/jsg/jsg.h b/src/workerd/jsg/jsg.h index 96c7ba0bf635..0c6e8cce34f5 100644 --- a/src/workerd/jsg/jsg.h +++ b/src/workerd/jsg/jsg.h @@ -2421,6 +2421,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 10ad1f63a4f8..95387e8c72f9 100644 --- a/src/workerd/jsg/jsvalue.c++ +++ b/src/workerd/jsg/jsvalue.c++ @@ -58,6 +58,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))); } @@ -131,6 +137,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()); @@ -444,6 +454,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 13d23ba18bd1..5cdb2ccf1c8e 100644 --- a/src/workerd/jsg/jsvalue.h +++ b/src/workerd/jsg/jsvalue.h @@ -330,6 +330,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; @@ -364,6 +365,7 @@ class JsObject final : public JsBase { using JsBase::JsBase; void recursivelyFreeze(Lock&); + void seal(Lock&); JsObject jsonClone(Lock&); };