File
Blob: src/workerd/jsg/ser-test.c++
| 1 | // Copyright (c) 2017-2022 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | #include "jsg-test.h" |
| 6 | #include "ser.h" |
| 7 | |
| 8 | namespace workerd::jsg::test { |
| 9 | namespace { |
| 10 | |
| 11 | V8System v8System; |
| 12 | class ContextGlobalObject: public jsg::Object, public ContextGlobal {}; |
| 13 | |
| 14 | kj::Array<kj::byte> lastSerializedData; |
| 15 | |
| 16 | struct SerTestContext: public ContextGlobalObject { |
| 17 | enum class SerializationTag { |
| 18 | FOO, |
| 19 | BAR, |
| 20 | BAZ, |
| 21 | QUX, |
| 22 | }; |
| 23 | |
| 24 | struct Foo: public jsg::Object { |
| 25 | uint i; |
| 26 | Foo(uint i): i(i) {} |
| 27 | |
| 28 | static jsg::Ref<Foo> constructor(jsg::Lock& js, uint i) { |
| 29 | return js.alloc<Foo>(i); |
| 30 | } |
| 31 | |
| 32 | int getI() { |
| 33 | return i; |
| 34 | } |
| 35 | |
| 36 | JSG_RESOURCE_TYPE(Foo) { |
| 37 | JSG_READONLY_PROTOTYPE_PROPERTY(i, getI); |
| 38 | } |
| 39 | |
| 40 | void serialize(jsg::Lock& js, jsg::Serializer& serializer) { |
| 41 | serializer.writeRawUint32(i); |
| 42 | } |
| 43 | static jsg::Ref<Foo> deserialize(Lock& js, SerializationTag tag, Deserializer& deserializer) { |
| 44 | KJ_ASSERT(tag == SerializationTag::FOO); |
| 45 | |
| 46 | // Intentionally deserialize differently so we can detect it. |
| 47 | return js.alloc<Foo>(deserializer.readRawUint32() + 2); |
| 48 | } |
| 49 | JSG_SERIALIZABLE(SerializationTag::FOO); |
| 50 | }; |
| 51 | |
| 52 | struct Bar: public jsg::Object { |
| 53 | kj::String text; |
| 54 | Bar(kj::String text): text(kj::mv(text)) {} |
| 55 | |
| 56 | static jsg::Ref<Bar> constructor(jsg::Lock& js, kj::String text) { |
| 57 | return js.alloc<Bar>(kj::mv(text)); |
| 58 | } |
| 59 | |
| 60 | kj::String getText() { |
| 61 | return kj::str(text); |
| 62 | } |
| 63 | |
| 64 | JSG_RESOURCE_TYPE(Bar) { |
| 65 | JSG_READONLY_PROTOTYPE_PROPERTY(text, getText); |
| 66 | } |
| 67 | |
| 68 | void serialize(jsg::Lock& js, jsg::Serializer& serializer) { |
| 69 | serializer.writeRawUint64(text.size()); |
| 70 | serializer.writeRawBytes(text.asBytes()); |
| 71 | } |
| 72 | static jsg::Ref<Bar> deserialize(Lock& js, SerializationTag tag, Deserializer& deserializer) { |
| 73 | KJ_ASSERT(tag == SerializationTag::BAR); |
| 74 | |
| 75 | size_t size = deserializer.readRawUint64(); |
| 76 | auto bytes = deserializer.readRawBytes(size); |
| 77 | // Intentionally deserialize differently so we can detect it. |
| 78 | return js.alloc<Bar>(kj::str(bytes.asChars(), '!')); |
| 79 | } |
| 80 | JSG_SERIALIZABLE(SerializationTag::BAR); |
| 81 | }; |
| 82 | |
| 83 | struct Baz: public jsg::Object { |
| 84 | bool serializeThrows; |
| 85 | Baz(bool serializeThrows): serializeThrows(serializeThrows) {} |
| 86 | static jsg::Ref<Baz> constructor(jsg::Lock& js, bool serializeThrows) { |
| 87 | return js.alloc<Baz>(serializeThrows); |
| 88 | } |
| 89 | |
| 90 | JSG_RESOURCE_TYPE(Baz) {} |
| 91 | |
| 92 | void serialize(jsg::Lock& js, jsg::Serializer& serializer) { |
| 93 | JSG_REQUIRE(!serializeThrows, Error, "throw from serialize()"); |
| 94 | } |
| 95 | static jsg::Ref<Bar> deserialize(Lock& js, SerializationTag tag, Deserializer& deserializer) { |
| 96 | JSG_FAIL_REQUIRE(Error, "throw from deserialize()"); |
| 97 | } |
| 98 | JSG_SERIALIZABLE(SerializationTag::BAZ); |
| 99 | }; |
| 100 | |
| 101 | // Qux is like Bar but serializes its string by converting it to a JS value first. |
| 102 | struct Qux: public jsg::Object { |
| 103 | kj::String text; |
| 104 | Qux(kj::String text): text(kj::mv(text)) {} |
| 105 | |
| 106 | static jsg::Ref<Qux> constructor(jsg::Lock& js, kj::String text) { |
| 107 | return js.alloc<Qux>(kj::mv(text)); |
| 108 | } |
| 109 | |
| 110 | kj::String getText() { |
| 111 | return kj::str(text); |
| 112 | } |
| 113 | |
| 114 | JSG_RESOURCE_TYPE(Qux) { |
| 115 | JSG_READONLY_PROTOTYPE_PROPERTY(text, getText); |
| 116 | } |
| 117 | |
| 118 | void serialize( |
| 119 | jsg::Lock& js, jsg::Serializer& serializer, const TypeHandler<kj::String>& stringHandler) { |
| 120 | // V2 prefers to serialize the string as a JS value. |
| 121 | serializer.write(js, JsValue(stringHandler.wrap(js, kj::str(text, '?')))); |
| 122 | } |
| 123 | static jsg::Ref<Qux> deserialize(Lock& js, |
| 124 | SerializationTag tag, |
| 125 | Deserializer& deserializer, |
| 126 | const TypeHandler<kj::String>& stringHandler) { |
| 127 | KJ_ASSERT(tag == SerializationTag::QUX); |
| 128 | |
| 129 | return js.alloc<Qux>( |
| 130 | KJ_ASSERT_NONNULL(stringHandler.tryUnwrap(js, deserializer.readValue(js)))); |
| 131 | } |
| 132 | JSG_SERIALIZABLE(SerializationTag::QUX); |
| 133 | }; |
| 134 | |
| 135 | JsValue roundTrip(Lock& js, JsValue in) { |
| 136 | auto content = ({ |
| 137 | Serializer ser(js); |
| 138 | ser.write(js, in); |
| 139 | ser.release(); |
| 140 | }); |
| 141 | |
| 142 | auto result = ({ |
| 143 | Deserializer deser(js, content); |
| 144 | deser.readValue(js); |
| 145 | }); |
| 146 | |
| 147 | // Save the last serialization off to the side. |
| 148 | lastSerializedData = kj::mv(content.data); |
| 149 | |
| 150 | return result; |
| 151 | } |
| 152 | |
| 153 | JSG_RESOURCE_TYPE(SerTestContext) { |
| 154 | JSG_NESTED_TYPE(Foo); |
| 155 | JSG_NESTED_TYPE(Bar); |
| 156 | JSG_NESTED_TYPE(Baz); |
| 157 | JSG_NESTED_TYPE(Qux); |
| 158 | JSG_METHOD(roundTrip); |
| 159 | } |
| 160 | }; |
| 161 | JSG_DECLARE_ISOLATE_TYPE(SerTestIsolate, |
| 162 | SerTestContext, |
| 163 | SerTestContext::Foo, |
| 164 | SerTestContext::Bar, |
| 165 | SerTestContext::Baz, |
| 166 | SerTestContext::Qux); |
| 167 | |
| 168 | // Define a whole second JSG isolate type that contains "updated" code where Bar no longer wraps |
| 169 | // a string, it wraps an arbitrary value. |
| 170 | struct SerTestContextV2: public ContextGlobalObject { |
| 171 | enum class SerializationTag { FOO, BAR_OLD, BAZ, QUX, BAR_V2 }; |
| 172 | |
| 173 | struct Bar: public jsg::Object { |
| 174 | JsRef<JsValue> val; |
| 175 | Bar(JsRef<JsValue> val): val(kj::mv(val)) {} |
| 176 | |
| 177 | static jsg::Ref<Bar> constructor(jsg::Lock& js, JsRef<JsValue> val) { |
| 178 | return js.alloc<Bar>(kj::mv(val)); |
| 179 | } |
| 180 | |
| 181 | JsRef<JsValue> getVal(Lock& js) { |
| 182 | return val.addRef(js); |
| 183 | } |
| 184 | |
| 185 | JSG_RESOURCE_TYPE(Bar) { |
| 186 | JSG_READONLY_PROTOTYPE_PROPERTY(val, getVal); |
| 187 | } |
| 188 | |
| 189 | void serialize(jsg::Lock& js, jsg::Serializer& serializer) { |
| 190 | // V2 just writes a value! |
| 191 | serializer.write(js, JsValue(val.getHandle(js))); |
| 192 | } |
| 193 | static jsg::Ref<Bar> deserialize(Lock& js, SerializationTag tag, Deserializer& deserializer) { |
| 194 | if (tag == SerializationTag::BAR_OLD) { |
| 195 | // Oh, it's an old value. |
| 196 | size_t size = deserializer.readRawUint64(); |
| 197 | auto bytes = deserializer.readRawBytes(size); |
| 198 | |
| 199 | return js.alloc<Bar>(JsRef<JsValue>(js, js.str(kj::str("old:", bytes.asChars())))); |
| 200 | } else { |
| 201 | KJ_ASSERT(tag == SerializationTag::BAR_V2); |
| 202 | |
| 203 | return js.alloc<Bar>(JsRef<JsValue>(js, deserializer.readValue(js))); |
| 204 | } |
| 205 | } |
| 206 | JSG_SERIALIZABLE(SerializationTag::BAR_V2, SerializationTag::BAR_OLD); |
| 207 | }; |
| 208 | |
| 209 | JsValue roundTrip(Lock& js, JsValue in) { |
| 210 | auto content = ({ |
| 211 | Serializer ser(js); |
| 212 | ser.write(js, in); |
| 213 | ser.release(); |
| 214 | }); |
| 215 | |
| 216 | auto result = ({ |
| 217 | Deserializer deser(js, content); |
| 218 | deser.readValue(js); |
| 219 | }); |
| 220 | |
| 221 | // Save the last serialization off to the side. |
| 222 | lastSerializedData = kj::mv(content.data); |
| 223 | |
| 224 | return result; |
| 225 | } |
| 226 | |
| 227 | JsValue deserializeLast(Lock& js) { |
| 228 | Deserializer deser(js, lastSerializedData); |
| 229 | return deser.readValue(js); |
| 230 | } |
| 231 | |
| 232 | JSG_RESOURCE_TYPE(SerTestContextV2) { |
| 233 | JSG_NESTED_TYPE(Bar); |
| 234 | JSG_METHOD(roundTrip); |
| 235 | JSG_METHOD(deserializeLast); |
| 236 | } |
| 237 | }; |
| 238 | JSG_DECLARE_ISOLATE_TYPE(SerTestIsolateV2, SerTestContextV2, SerTestContextV2::Bar); |
| 239 | |
| 240 | KJ_TEST("serialization") { |
| 241 | Evaluator<SerTestContext, SerTestIsolate> e(v8System); |
| 242 | |
| 243 | // Test serializing built-in values. |
| 244 | e.expectEval("roundTrip(123)", "number", "123"); |
| 245 | e.expectEval("JSON.stringify(roundTrip({foo: 123}))", "string", "{\"foo\":123}"); |
| 246 | |
| 247 | // Test serializing host objects. |
| 248 | e.expectEval("roundTrip(new Foo(123)).i", "number", "125"); |
| 249 | e.expectEval("roundTrip(new Qux(\"hello\")).text", "string", "hello?"); |
| 250 | e.expectEval("roundTrip(new Bar(\"hello\")).text", "string", "hello!"); |
| 251 | |
| 252 | // Test throwing from serialize/deserialize |
| 253 | e.expectEval("roundTrip(new Baz(true)).text", "throws", "Error: throw from serialize()"); |
| 254 | e.expectEval("roundTrip(new Baz(false)).text", "throws", "Error: throw from deserialize()"); |
| 255 | |
| 256 | // Let's set up the "new version" of the code. |
| 257 | Evaluator<SerTestContextV2, SerTestIsolateV2> e2(v8System); |
| 258 | |
| 259 | // This will deserialize the last-serialized bytes from above, where we serialized Bar("hello"). |
| 260 | // However, it is using a "new version" of the code where Bar's serialization has changed, but |
| 261 | // the old version is still accepted. |
| 262 | e2.expectEval("deserializeLast().val", "string", "old:hello"); |
| 263 | |
| 264 | // Also try round-tripping the new version. It now accepts arbitrary values, not just strings. |
| 265 | e2.expectEval("roundTrip(new Bar(123)).val", "number", "123"); |
| 266 | |
| 267 | // Note that cycles through host objects are correctly serialized! |
| 268 | // |
| 269 | // V8 BUG ALERT: The below works if we use `obj` as the root of serialization, but NOT if we |
| 270 | // use `bar` as the root. The reason is a flaw in the design of V8's callbacks for parsing |
| 271 | // host objects. V8 makes a single callback to the embedder which fully reads the object and |
| 272 | // returns a handle. However, this means that V8 cannot put the object into the backreference |
| 273 | // table until this callback returns. If, while parsing the object, we encounter a |
| 274 | // backreference to the object itself (a cycle), the deserializer will find the backreference |
| 275 | // is not in the table and therefore raises an error. This is not a problem for native objects |
| 276 | // because V8 allocates the object first, then immediately adds it to the backreference table, |
| 277 | // and only then parses its content -- and this is why everything works fine if we start with |
| 278 | // a native object as the root, as in this test. The API for host objects needs to be extended |
| 279 | // somehow to allow the object to be inserted into the table before parsing its content. |
| 280 | e2.expectEval("let obj = {i: 321};\n" |
| 281 | "let bar = new Bar(obj);\n" |
| 282 | "obj.bar = bar;\n" |
| 283 | "roundTrip(obj).bar.val.bar.val.bar.val.i", |
| 284 | "number", "321"); |
| 285 | } |
| 286 | } // namespace |
| 287 | } // namespace workerd::jsg::test |