Skip to content

Commit

Permalink
Implements cloudflare:compatibility-flags API
Browse files Browse the repository at this point in the history
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']);  // 'on' or 'off'
console.log(compatFlags['url_original']);  // 'on' or 'off'
```
  • Loading branch information
jasnell committed Aug 13, 2024
1 parent 83a8b89 commit 5c03173
Show file tree
Hide file tree
Showing 8 changed files with 281 additions and 0 deletions.
161 changes: 161 additions & 0 deletions 0001-Implements-cloudflare-compatibility-flags-API.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
From fdac25bc6ad66aed94bf7e89e0f2afc1158c32d8 Mon Sep 17 00:00:00 2001
From: James M Snell <[email protected]>
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 <workerd/jsg/modules.h>
#include <capnp/dynamic.h>
#include <node/node.capnp.h>
+#include <workerd/io/compatibility-date.h>

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<bool>();
+ 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<v8::String> 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

5 changes: 5 additions & 0 deletions src/cloudflare/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
45 changes: 45 additions & 0 deletions src/workerd/api/node/node.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <workerd/jsg/modules-new.h>
#include <capnp/dynamic.h>
#include <node/node.capnp.h>
#include <workerd/io/compatibility-date.h>

namespace workerd::api::node {

Expand All @@ -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<bool>();
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...
Expand Down
38 changes: 38 additions & 0 deletions src/workerd/api/tests/compat-flags-test.js
Original file line number Diff line number Diff line change
@@ -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'));
}
}
15 changes: 15 additions & 0 deletions src/workerd/api/tests/compat-flags-test.wd-test
Original file line number Diff line number Diff line change
@@ -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"],
)
),
],
);
1 change: 1 addition & 0 deletions src/workerd/jsg/jsg.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions src/workerd/jsg/jsvalue.c++
Original file line number Diff line number Diff line change
Expand Up @@ -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<v8::String> nameStr = js.strIntern(name);
check(inner->DefineOwnProperty(js.v8Context(), nameStr, value,
static_cast<v8::PropertyAttribute>(v8::ReadOnly | v8::DontDelete)));
}

JsValue JsObject::get(Lock& js, const JsValue& name) {
return JsValue(check(inner->Get(js.v8Context(), name.inner)));
}
Expand Down Expand Up @@ -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<jsg::JsObject>());
Expand Down Expand Up @@ -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));
}
Expand Down
2 changes: 2 additions & 0 deletions src/workerd/jsg/jsvalue.h
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ class JsObject final : public JsBase<v8::Object, JsObject> {

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;

Expand Down Expand Up @@ -364,6 +365,7 @@ class JsObject final : public JsBase<v8::Object, JsObject> {
using JsBase<v8::Object, JsObject>::JsBase;

void recursivelyFreeze(Lock&);
void seal(Lock&);
JsObject jsonClone(Lock&);
};

Expand Down

0 comments on commit 5c03173

Please sign in to comment.