File
Blob: src/workerd/api/form-data.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 "form-data.h" |
| 6 | |
| 7 | #include "util.h" |
| 8 | |
| 9 | #include <workerd/io/io-util.h> |
| 10 | #include <workerd/util/mimetype.h> |
| 11 | #include <workerd/util/own-util.h> |
| 12 | |
| 13 | #include <kj/compat/http.h> |
| 14 | #include <kj/parse/char.h> |
| 15 | #include <kj/vector.h> |
| 16 | |
| 17 | #include <algorithm> |
| 18 | #include <regex> |
| 19 | |
| 20 | #if !_MSC_VER |
| 21 | #include <strings.h> |
| 22 | #endif |
| 23 | |
| 24 | namespace workerd::api { |
| 25 | |
| 26 | namespace { |
| 27 | // Like split() in kj/compat/url.c++, but splits at a substring rather than a character. |
| 28 | kj::ArrayPtr<const char> splitAtSubString(kj::ArrayPtr<const char>& text, kj::StringPtr subString) { |
| 29 | // TODO(perf): Use a Boyer-Moore search? |
| 30 | auto iter = std::search(text.begin(), text.end(), subString.begin(), subString.end()); |
| 31 | auto result = kj::arrayPtr(text.begin(), iter - text.begin()); |
| 32 | text = |
| 33 | text.slice(kj::min(text.size(), result.end() - text.begin() + subString.size()), text.size()); |
| 34 | return result; |
| 35 | } |
| 36 | |
| 37 | struct FormDataHeaderTable { |
| 38 | kj::HttpHeaderId contentDispositionId; |
| 39 | kj::Own<kj::HttpHeaderTable> table; |
| 40 | |
| 41 | FormDataHeaderTable(kj::HttpHeaderTable::Builder builder) |
| 42 | : contentDispositionId(builder.add("Content-Disposition")), |
| 43 | table(builder.build()) {} |
| 44 | }; |
| 45 | |
| 46 | const FormDataHeaderTable& getFormDataHeaderTable() { |
| 47 | static const FormDataHeaderTable table({}); |
| 48 | return table; |
| 49 | } |
| 50 | |
| 51 | namespace p = kj::parse; |
| 52 | constexpr auto httpIdentifier = p::oneOrMore(p::nameChar.orChar('-')); |
| 53 | constexpr auto quotedChar = p::oneOf(p::anyOfChars("\"\n\\").invert(), |
| 54 | // Chrome interprets "\<c>" as reducing to <c> for any character <c>, including double quote. |
| 55 | // (So "\n" = "n", etc.) |
| 56 | p::sequence(p::exactChar<'\\'>(), p::anyOfChars("\n").invert())); |
| 57 | constexpr auto contentDispositionParam = p::sequence(p::exactChar<';'>(), |
| 58 | p::discardWhitespace, |
| 59 | httpIdentifier, |
| 60 | p::discardWhitespace, |
| 61 | p::exactChar<'='>(), |
| 62 | p::discardWhitespace, |
| 63 | p::exactChar<'"'>(), |
| 64 | p::oneOrMore(quotedChar), |
| 65 | p::exactChar<'"'>(), |
| 66 | p::discardWhitespace); |
| 67 | constexpr auto contentDisposition = p::sequence( |
| 68 | p::discardWhitespace, httpIdentifier, p::discardWhitespace, p::many(contentDispositionParam)); |
| 69 | |
| 70 | kj::OneOf<jsg::Ref<File>, kj::String> blobToFile(jsg::Lock& js, |
| 71 | kj::StringPtr name, |
| 72 | kj::OneOf<jsg::Ref<File>, jsg::Ref<Blob>, kj::String> value, |
| 73 | jsg::Optional<kj::String> filename) { |
| 74 | auto fromBlob = [&](jsg::Ref<Blob> blob) { |
| 75 | kj::String fn; |
| 76 | KJ_IF_SOME(f, filename) { |
| 77 | fn = kj::mv(f); |
| 78 | } else { |
| 79 | fn = kj::str(name); |
| 80 | } |
| 81 | // The file is created with the same data as the blob (essentially as just |
| 82 | // a view of the same blob) to avoid copying the data. |
| 83 | return js.alloc<File>( |
| 84 | blob.addRef(), blob->getData(), kj::mv(fn), kj::str(blob->getType()), dateNow()); |
| 85 | }; |
| 86 | |
| 87 | KJ_SWITCH_ONEOF(value) { |
| 88 | KJ_CASE_ONEOF(file, jsg::Ref<File>) { |
| 89 | if (filename == kj::none) { |
| 90 | return kj::mv(file); |
| 91 | } else { |
| 92 | // Need to substitute filename. |
| 93 | return fromBlob(kj::mv(file)); |
| 94 | } |
| 95 | } |
| 96 | KJ_CASE_ONEOF(blob, jsg::Ref<Blob>) { |
| 97 | return fromBlob(kj::mv(blob)); |
| 98 | } |
| 99 | KJ_CASE_ONEOF(string, kj::String) { |
| 100 | return kj::mv(string); |
| 101 | } |
| 102 | } |
| 103 | KJ_UNREACHABLE; |
| 104 | } |
| 105 | |
| 106 | // Add the chars from `value` into `builder` escaping the characters '"' and '\n' using % |
| 107 | // encoding, exactly as Chrome does for Content-Disposition values. |
| 108 | void addEscapingQuotes(kj::Vector<char>& builder, kj::StringPtr value) { |
| 109 | // Chrome throws "Failed to fetch" if the name ends with a backslash. Otherwise it worries that |
| 110 | // the backslash may be interpreted as escaping the final quote. |
| 111 | JSG_REQUIRE(!value.endsWith("\\"), TypeError, "Name or filename can't end with backslash"); |
| 112 | |
| 113 | for (char c: value) { |
| 114 | switch (c) { |
| 115 | case '\"': |
| 116 | // Firefox supposedly escapes this as '\"', but Chrome chooses to use percent escapes, |
| 117 | // probably for fear of a buggy receiver who interprets the '"' as being the end of the |
| 118 | // string. There is no standard. |
| 119 | builder.addAll("%22"_kj); |
| 120 | break; |
| 121 | case '\n': |
| 122 | builder.addAll("%0A"_kj); |
| 123 | break; |
| 124 | case '\\': |
| 125 | // Chrome doesn't escape '\', but this awkwardly means that the '\' will be evaluated as |
| 126 | // an escape sequence on the other end. That seems like a bug. Let's not copy bugs. |
| 127 | builder.addAll("\\\\"_kj); |
| 128 | break; |
| 129 | default: |
| 130 | builder.add(c); |
| 131 | break; |
| 132 | } |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | void assertUtf8(const auto& params) { |
| 137 | KJ_IF_SOME(charset, params.find("charset"_kj)) { |
| 138 | JSG_REQUIRE(strcasecmp(charset.cStr(), "utf-8") == 0 || |
| 139 | strcasecmp(charset.cStr(), "utf8") == 0 || |
| 140 | strcasecmp(charset.cStr(), "unicode-1-1-utf-8") == 0, |
| 141 | TypeError, "Non-utf-8 application/x-www-form-urlencoded body."); |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | } // namespace |
| 146 | |
| 147 | // ======================================================================================= |
| 148 | // FormData implementation |
| 149 | |
| 150 | void FormData::parseFormDataImpl( |
| 151 | kj::ArrayPtr<const char> rawText, kj::StringPtr boundary, ParseCallback callback) { |
| 152 | // multipart/form-data messages are delimited by <CRLF>--<boundary>. We want to be able to handle |
| 153 | // omitted carriage returns, though, so our delimiter only matches against a preceding line feed. |
| 154 | const auto delimiter = kj::str("\n--", boundary); |
| 155 | |
| 156 | // We want to slice off the delimiter's preceding newline for the initial search, because the very |
| 157 | // first instance does not require one. In every subsequent multipart message, the preceding |
| 158 | // newline is required. |
| 159 | auto message = splitAtSubString(rawText, delimiter.slice(1)); |
| 160 | |
| 161 | JSG_REQUIRE(rawText.size() > 0, TypeError, |
| 162 | "No initial boundary string (or you have a truncated message)."); |
| 163 | |
| 164 | static const auto done = [](kj::ArrayPtr<const char>& body) { |
| 165 | // Consume any (CR)LF characters that trailed the boundary and indicate continuation, or consume |
| 166 | // the terminal "--" characters and indicate termination, or throw an error. |
| 167 | if (body.startsWith("\n"_kj)) { |
| 168 | body = body.slice(1, body.size()); |
| 169 | } else if (body.startsWith("\r\n"_kj)) { |
| 170 | body = body.slice(2, body.size()); |
| 171 | } else if (body.startsWith("--"_kj)) { |
| 172 | // We're done! |
| 173 | return true; |
| 174 | } else { |
| 175 | JSG_FAIL_REQUIRE(TypeError, "Boundary string was not succeeded by CRLF, LF, or '--'."); |
| 176 | } |
| 177 | return false; |
| 178 | }; |
| 179 | |
| 180 | constexpr auto staticRegexFlags = |
| 181 | std::regex_constants::ECMAScript | std::regex_constants::optimize; |
| 182 | |
| 183 | static const auto headerTerminationRegex = std::regex("\r?\n\r?\n", staticRegexFlags); |
| 184 | |
| 185 | std::cmatch match; |
| 186 | |
| 187 | auto& formDataHeaderTable = getFormDataHeaderTable(); |
| 188 | |
| 189 | while (!done(rawText)) { |
| 190 | JSG_REQUIRE(std::regex_search(rawText.begin(), rawText.end(), match, headerTerminationRegex), |
| 191 | TypeError, "No multipart message header termination found."); |
| 192 | |
| 193 | // TODO(cleanup): Use kj-http to parse multipart headers. Right now that API isn't public, so |
| 194 | // I'm just using a regex. For reference, multipart/form-data supports the following three |
| 195 | // headers (https://tools.ietf.org/html/rfc7578#section-4.8): |
| 196 | // |
| 197 | // Content-Disposition (required) |
| 198 | // Content-Type (optional, recommended for files) |
| 199 | // Content-Transfer-Encoding (for 7-bit encoding, deprecated in HTTP contexts) |
| 200 | // |
| 201 | // TODO(soon): Read the Content-Type to support files. |
| 202 | |
| 203 | auto headersText = kj::str(rawText.first(match[0].second - rawText.begin())); |
| 204 | rawText = rawText.slice(match[0].second - rawText.begin(), rawText.size()); |
| 205 | |
| 206 | kj::HttpHeaders headers(*formDataHeaderTable.table); |
| 207 | JSG_REQUIRE(headers.tryParse(headersText), TypeError, "FormData part had invalid headers."); |
| 208 | |
| 209 | kj::StringPtr disposition = |
| 210 | JSG_REQUIRE_NONNULL(headers.get(formDataHeaderTable.contentDispositionId), TypeError, |
| 211 | "No valid Content-Disposition header found in FormData part."); |
| 212 | |
| 213 | kj::Maybe<kj::String> maybeName; |
| 214 | kj::Maybe<kj::String> filename; |
| 215 | { |
| 216 | p::IteratorInput<char, const char*> input(disposition.begin(), disposition.end()); |
| 217 | auto result = JSG_REQUIRE_NONNULL(contentDisposition(input), TypeError, |
| 218 | "Invalid Content-Disposition header found in FormData part."); |
| 219 | JSG_REQUIRE(kj::get<0>(result) == "form-data"_kj.asArray(), TypeError, |
| 220 | "Content-Disposition header for FormData part must have the value \"form-data\", " |
| 221 | "possibly followed by parameters. Got: \"", |
| 222 | kj::get<0>(result), "\""); |
| 223 | |
| 224 | for (auto& param: kj::get<1>(result)) { |
| 225 | if (kj::get<0>(param) == "name"_kj.asArray()) { |
| 226 | maybeName = kj::str(kj::get<1>(param)); |
| 227 | } else if (kj::get<0>(param) == "filename"_kj.asArray()) { |
| 228 | filename = kj::str(kj::get<1>(param)); |
| 229 | } |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | kj::String name = JSG_REQUIRE_NONNULL(kj::mv(maybeName), TypeError, |
| 234 | "Content-Disposition header in FormData part is missing a name."); |
| 235 | |
| 236 | kj::Maybe<kj::StringPtr> type = headers.get(kj::HttpHeaderId::CONTENT_TYPE); |
| 237 | |
| 238 | message = splitAtSubString(rawText, delimiter); |
| 239 | JSG_REQUIRE( |
| 240 | rawText.size() > 0, TypeError, "No subsequent boundary string after multipart message."); |
| 241 | |
| 242 | if (message.size() > 0) { |
| 243 | // If we skipped a CR, we must avoid including it in the message data. |
| 244 | message = message.first(message.size() - static_cast<uint>(message.back() == '\r')); |
| 245 | } |
| 246 | |
| 247 | callback(name, filename.map([](auto& str) { return str.asPtr(); }), type, message.asBytes()); |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | void FormData::parse(jsg::Lock& js, |
| 252 | kj::ArrayPtr<const char> rawText, |
| 253 | kj::StringPtr contentType, |
| 254 | bool convertFilesToStrings) { |
| 255 | KJ_IF_SOME(parsed, MimeType::tryParse(contentType)) { |
| 256 | auto& params = parsed.params(); |
| 257 | if (MimeType::FORM_DATA == parsed) { |
| 258 | auto& boundary = JSG_REQUIRE_NONNULL(params.find("boundary"_kj), TypeError, |
| 259 | "No boundary string in Content-Type header. The multipart/form-data MIME " |
| 260 | "type requires a boundary parameter, e.g. 'Content-Type: multipart/form-data; " |
| 261 | "boundary=\"abcd\"'. See RFC 7578, section 4."); |
| 262 | |
| 263 | parseFormDataImpl(rawText, boundary, |
| 264 | [&](kj::StringPtr name, kj::Maybe<kj::StringPtr> maybeFilename, |
| 265 | kj::Maybe<kj::StringPtr> maybeType, kj::ArrayPtr<const kj::byte> message) { |
| 266 | KJ_IF_SOME(filename, maybeFilename) { |
| 267 | if (convertFilesToStrings) { |
| 268 | auto messageData = kj::heapArray<char>(message.asChars()); |
| 269 | data.add(FormData::Entry{ |
| 270 | .name = kj::str(name), |
| 271 | .value = kj::str(kj::mv(messageData)), |
| 272 | }); |
| 273 | } else { |
| 274 | auto bytes = jsg::JsArrayBuffer::create(js, message); |
| 275 | data.add(FormData::Entry{.name = kj::str(name), |
| 276 | .value = js.alloc<File>(js, jsg::JsBufferSource(bytes), kj::str(filename), |
| 277 | kj::str(maybeType.orDefault(nullptr)), dateNow())}); |
| 278 | } |
| 279 | } else { |
| 280 | auto messageData = kj::heapArray<char>(message.asChars()); |
| 281 | data.add(FormData::Entry{ |
| 282 | .name = kj::str(name), |
| 283 | .value = kj::str(kj::mv(messageData)), |
| 284 | }); |
| 285 | } |
| 286 | }); |
| 287 | return; |
| 288 | } else if (MimeType::FORM_URLENCODED == parsed) { |
| 289 | // Let's read the charset so we can barf if the body isn't UTF-8. |
| 290 | // |
| 291 | // TODO(conform): Transcode to UTF-8, like the spec tells us to. |
| 292 | assertUtf8(params); |
| 293 | kj::Vector<kj::Url::QueryParam> query; |
| 294 | parseQueryString(query, kj::mv(rawText)); |
| 295 | data.reserve(query.size()); |
| 296 | for (auto& param: query) { |
| 297 | data.add(Entry{ |
| 298 | .name = kj::str(param.name), |
| 299 | .value = kj::str(param.value), |
| 300 | }); |
| 301 | } |
| 302 | return; |
| 303 | } |
| 304 | } |
| 305 | JSG_FAIL_REQUIRE(TypeError, |
| 306 | "Unrecognized Content-Type header value. FormData can only " |
| 307 | "parse the following MIME types: ", |
| 308 | MimeType::FORM_DATA.toString(), ", ", MimeType::FORM_URLENCODED.toString()); |
| 309 | } |
| 310 | |
| 311 | kj::Array<kj::byte> FormData::serialize(kj::ArrayPtr<const char> boundary) { |
| 312 | // Boundary string requirement per RFC7578 |
| 313 | JSG_REQUIRE(boundary.size() > 0 && boundary.size() <= 70, TypeError, |
| 314 | "Length of multipart/form-data boundary string must be in the range [1, 70]."); |
| 315 | |
| 316 | // TODO(perf): We should be able to trivially calculate the length of the serialized form data |
| 317 | // beforehand. I tried, but apparently my math REALLY sucks and I hate memory overruns, so ... |
| 318 | auto builder = kj::Vector<char>{}; |
| 319 | |
| 320 | for (auto& kv: data) { |
| 321 | builder.addAll("--"_kj); |
| 322 | builder.addAll(boundary); |
| 323 | builder.addAll("\r\n"_kj); |
| 324 | builder.addAll("Content-Disposition: form-data; name=\""_kj); |
| 325 | addEscapingQuotes(builder, kv.name); |
| 326 | KJ_SWITCH_ONEOF(kv.value) { |
| 327 | KJ_CASE_ONEOF(text, kj::String) { |
| 328 | builder.addAll("\"\r\n\r\n"_kj); |
| 329 | builder.addAll(text); |
| 330 | } |
| 331 | KJ_CASE_ONEOF(file, jsg::Ref<File>) { |
| 332 | builder.addAll("\"; filename=\""_kj); |
| 333 | addEscapingQuotes(builder, file->getName()); |
| 334 | builder.addAll("\"\r\nContent-Type: "_kj); |
| 335 | auto type = file->getType(); |
| 336 | if (type == nullptr) { |
| 337 | builder.addAll(MimeType::OCTET_STREAM.toString()); |
| 338 | } else { |
| 339 | builder.addAll(type); |
| 340 | } |
| 341 | builder.addAll("\r\n\r\n"_kj); |
| 342 | builder.addAll(file->getData().asChars()); |
| 343 | } |
| 344 | } |
| 345 | builder.addAll("\r\n"_kj); |
| 346 | } |
| 347 | builder.addAll("--"_kj); |
| 348 | builder.addAll(boundary); |
| 349 | builder.addAll("--"_kj); |
| 350 | |
| 351 | return builder.releaseAsArray().releaseAsBytes(); |
| 352 | } |
| 353 | |
| 354 | FormData::EntryType FormData::clone(jsg::Lock& js, FormData::EntryType& value) { |
| 355 | KJ_SWITCH_ONEOF(value) { |
| 356 | KJ_CASE_ONEOF(file, jsg::Ref<File>) { |
| 357 | return file.addRef(); |
| 358 | } |
| 359 | KJ_CASE_ONEOF(string, kj::String) { |
| 360 | auto data = kj::heapArray<char>(string); |
| 361 | return kj::str(kj::mv(data)); |
| 362 | } |
| 363 | } |
| 364 | KJ_UNREACHABLE; |
| 365 | } |
| 366 | |
| 367 | jsg::Ref<FormData> FormData::constructor(jsg::Lock& js) { |
| 368 | return js.alloc<FormData>(); |
| 369 | } |
| 370 | |
| 371 | void FormData::append(jsg::Lock& js, |
| 372 | kj::String name, |
| 373 | kj::OneOf<jsg::Ref<File>, jsg::Ref<Blob>, kj::String> value, |
| 374 | jsg::Optional<kj::String> filename) { |
| 375 | auto filifiedValue = blobToFile(js, name, kj::mv(value), kj::mv(filename)); |
| 376 | data.add(Entry{kj::mv(name), kj::mv(filifiedValue)}); |
| 377 | } |
| 378 | |
| 379 | void FormData::delete_(kj::String name) { |
| 380 | auto pivot = |
| 381 | std::remove_if(data.begin(), data.end(), [&name](const auto& kv) { return kv.name == name; }); |
| 382 | data.truncate(pivot - data.begin()); |
| 383 | } |
| 384 | |
| 385 | kj::Maybe<kj::OneOf<jsg::Ref<File>, kj::String>> FormData::get(jsg::Lock& js, kj::String name) { |
| 386 | for (auto& [k, v]: data) { |
| 387 | if (k == name) { |
| 388 | return clone(js, v); |
| 389 | } |
| 390 | } |
| 391 | return kj::none; |
| 392 | } |
| 393 | |
| 394 | kj::Array<kj::OneOf<jsg::Ref<File>, kj::String>> FormData::getAll(jsg::Lock& js, kj::String name) { |
| 395 | kj::Vector<kj::OneOf<jsg::Ref<File>, kj::String>> result; |
| 396 | for (auto& [k, v]: data) { |
| 397 | if (k == name) { |
| 398 | result.add(clone(js, v)); |
| 399 | } |
| 400 | } |
| 401 | return result.releaseAsArray(); |
| 402 | } |
| 403 | |
| 404 | bool FormData::has(kj::String name) { |
| 405 | for (auto& [k, v]: data) { |
| 406 | if (k == name) { |
| 407 | return true; |
| 408 | } |
| 409 | } |
| 410 | return false; |
| 411 | } |
| 412 | |
| 413 | // Set the first element named `name` to `value`, then remove all the rest matching that name. |
| 414 | void FormData::set(jsg::Lock& js, |
| 415 | kj::String name, |
| 416 | kj::OneOf<jsg::Ref<File>, jsg::Ref<Blob>, kj::String> value, |
| 417 | jsg::Optional<kj::String> filename) { |
| 418 | const auto predicate = [name = name.slice(0)](const auto& kv) { return kv.name == name; }; |
| 419 | auto firstFound = std::find_if(data.begin(), data.end(), predicate); |
| 420 | if (firstFound != data.end()) { |
| 421 | firstFound->value = blobToFile(js, name, kj::mv(value), kj::mv(filename)); |
| 422 | auto pivot = std::remove_if(++firstFound, data.end(), predicate); |
| 423 | data.truncate(pivot - data.begin()); |
| 424 | } else { |
| 425 | append(js, kj::mv(name), kj::mv(value), kj::mv(filename)); |
| 426 | } |
| 427 | } |
| 428 | |
| 429 | jsg::Ref<FormData::EntryIterator> FormData::entries(jsg::Lock& js) { |
| 430 | return js.alloc<EntryIterator>(IteratorState{JSG_THIS}); |
| 431 | } |
| 432 | |
| 433 | jsg::Ref<FormData::KeyIterator> FormData::keys(jsg::Lock& js) { |
| 434 | return js.alloc<KeyIterator>(IteratorState{JSG_THIS}); |
| 435 | } |
| 436 | |
| 437 | jsg::Ref<FormData::ValueIterator> FormData::values(jsg::Lock& js) { |
| 438 | return js.alloc<ValueIterator>(IteratorState{JSG_THIS}); |
| 439 | } |
| 440 | |
| 441 | void FormData::forEach(jsg::Lock& js, |
| 442 | jsg::Function<void(EntryType, kj::StringPtr, jsg::Ref<FormData>)> callback, |
| 443 | jsg::Optional<jsg::Value> thisArg) { |
| 444 | // Here, if the thisArg is not passed, or is passed explicitly as a null or |
| 445 | // undefined, then undefined is used as the thisArg. |
| 446 | auto receiver = js.v8Undefined(); |
| 447 | KJ_IF_SOME(arg, thisArg) { |
| 448 | auto handle = arg.getHandle(js); |
| 449 | if (!handle->IsNullOrUndefined()) { |
| 450 | receiver = handle; |
| 451 | } |
| 452 | } |
| 453 | callback.setReceiver(js.v8Ref(receiver)); |
| 454 | |
| 455 | // On each iteration of the for loop, a JavaScript callback is invoked. If a new |
| 456 | // item is appended to the URLSearchParams within that function, the loop must pick |
| 457 | // it up. Using the classic for (;;) syntax here allows for that. However, this does |
| 458 | // mean that it's possible for a user to trigger an infinite loop here if new items |
| 459 | // are added to the search params unconditionally on each iteration. |
| 460 | // Silence clang-tidy warning, using an iterator would not work correctly if callback |
| 461 | // increases the array size. |
| 462 | // NOLINTNEXTLINE(modernize-loop-convert) |
| 463 | for (size_t i = 0; i < this->data.size(); i++) { |
| 464 | auto& [key, value] = this->data[i]; |
| 465 | callback(js, clone(js, value), key, JSG_THIS); |
| 466 | } |
| 467 | } |
| 468 | |
| 469 | } // namespace workerd::api |