From 39159e7af1763d27c51ae2a00b78190772c624fb Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Fri, 13 Dec 2024 16:44:35 -0800 Subject: [PATCH] implement request and response properties --- .../builtins/fetch/request-response.cpp | 422 ++++++++++++++++++ .../fastly/builtins/fetch/request-response.h | 29 +- runtime/fastly/host-api/host_api.cpp | 15 +- runtime/fastly/host-api/host_api_fastly.h | 13 +- types/globals.d.ts | 12 +- 5 files changed, 474 insertions(+), 17 deletions(-) diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index b1cc3759cd..0d8dbf7edc 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -530,6 +530,20 @@ JSObject *Response::headers(JSContext *cx, JS::HandleObject obj) { return headers; } +bool Request::isCacheable_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto handle = request_handle(self); + auto res = handle.is_cacheable(); + if (auto *err = res.to_err()) { + HANDLE_ERROR(cx, *err); + return false; + } + + args.rval().setBoolean(res.unwrap()); + return true; +} + // Headers are committed when making the request or response. // We ensure the headers are in the ContentOnly or CachedInContent state for // future reads and mutations, and then copy them into a new handle created for the @@ -1704,6 +1718,7 @@ const JSPropertySpec Request::properties[] = { JS_PSG("backend", backend_get, JSPROP_ENUMERATE), JS_PSG("body", body_get, JSPROP_ENUMERATE), JS_PSG("bodyUsed", bodyUsed_get, JSPROP_ENUMERATE), + JS_PSG("isCacheable", isCacheable_get, JSPROP_ENUMERATE), JS_STRING_SYM_PS(toStringTag, "Request", JSPROP_READONLY), JS_PS_END, }; @@ -2962,10 +2977,414 @@ const JSPropertySpec Response::properties[] = { JS_PSG("ip", ip_get, JSPROP_ENUMERATE), JS_PSG("port", port_get, JSPROP_ENUMERATE), JS_PSG("backend", backend_get, JSPROP_ENUMERATE), + JS_PSG("isCacheable", isCacheable_get, JSPROP_ENUMERATE), + JS_PSG("cached", cached_get, JSPROP_ENUMERATE), + JS_PSG("isStale", isStale_get, JSPROP_ENUMERATE), + JS_PSGS("ttl", ttl_get, ttl_set, JSPROP_ENUMERATE), + JS_PSG("age", age_get, JSPROP_ENUMERATE), + JS_PSGS("swr", swr_get, swr_set, JSPROP_ENUMERATE), + JS_PSGS("vary", vary_get, vary_set, JSPROP_ENUMERATE), + JS_PSGS("surrogateKeys", surrogateKeys_get, surrogateKeys_set, JSPROP_ENUMERATE), + JS_PSGS("pci", pci_get, pci_set, JSPROP_ENUMERATE), JS_STRING_SYM_PS(toStringTag, "Response", JSPROP_READONLY), JS_PS_END, }; +host_api::HttpCacheEntry Response::cache_entry(JSObject *obj) { + MOZ_ASSERT(is_instance(obj)); + auto val = JS::GetReservedSlot(obj, static_cast(Slots::CacheEntry)); + if (val.isInt32()) { + return host_api::HttpCacheEntry(val.toInt32()); + } + return host_api::HttpCacheEntry(); +} + +host_api::HttpStorageAction Response::storage_action(JSObject *obj) { + MOZ_ASSERT(is_instance(obj)); + auto val = JS::GetReservedSlot(obj, static_cast(Slots::StorageAction)); + return val.isUndefined() ? host_api::HttpStorageAction::DoNotStore : // Default if not set + static_cast(val.toInt32()); +} + +host_api::HttpCacheWriteOptions *Response::cache_write_options(JSObject *obj) { + MOZ_ASSERT(is_instance(obj)); + auto val = JS::GetReservedSlot(obj, static_cast(Slots::CacheWriteOptions)); + if (val.isUndefined()) { + return nullptr; + } + return static_cast(val.toPrivate()); +} + +bool Response::isCacheable_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto action = storage_action(self); + args.rval().setBoolean(action == host_api::HttpStorageAction::Insert || + action == host_api::HttpStorageAction::Update); + return true; +} + +bool Response::cached_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + auto entry = cache_entry(self); + args.rval().setBoolean(entry.is_valid()); + return true; +} + +bool Response::isStale_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto entry = cache_entry(self); + if (!entry.is_valid()) { + args.rval().setUndefined(); + return true; + } + + auto res = entry.get_state(); + if (auto *err = res.to_err()) { + HANDLE_ERROR(cx, *err); + return false; + } + + args.rval().setBoolean(res.unwrap().is_stale()); + return true; +} + +bool Response::ttl_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto opts = cache_write_options(self); + if (!opts) { + args.rval().setUndefined(); + return true; + } + + args.rval().setNumber(static_cast(opts->max_age_ns) / 1e9); + return true; +} + +bool Response::age_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto opts = cache_write_options(self); + if (!opts) { + args.rval().setUndefined(); + return true; + } + + args.rval().setNumber(static_cast(opts->initial_age_ns.value_or(0)) / 1e9); + return true; +} + +bool Response::swr_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto opts = cache_write_options(self); + if (!opts || !opts->stale_while_revalidate_ns) { + args.rval().setUndefined(); + return true; + } + + args.rval().setNumber(static_cast(opts->stale_while_revalidate_ns.value()) / 1e9); + return true; +} + +// TODO: Convert from Set to Array +bool Response::vary_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto opts = cache_write_options(self); + if (!opts) { + args.rval().setUndefined(); + return true; + } + + if (!opts->vary_rule.has_value()) { + // Create empty Set if no vary rule + JS::RootedObject set(cx, JS::NewSetObject(cx)); + if (!set) { + return false; + } + args.rval().setObject(*set); + return true; + } + + // Split vary rule on commas and trim whitespace + std::string_view rule_str(opts->vary_rule.value().begin(), opts->vary_rule.value().end()); + std::vector headers; + size_t pos = 0; + while (pos < rule_str.length()) { + // Skip leading whitespace + while (pos < rule_str.length() && std::isspace(rule_str[pos])) { + pos++; + } + + // Find next space + size_t comma = rule_str.find(' ', pos); + + std::string_view header; + if (comma == std::string_view::npos) { + header = rule_str.substr(pos); + pos = rule_str.length(); + } else { + header = rule_str.substr(pos, comma - pos); + pos = comma + 1; + } + + // Trim trailing whitespace + while (!header.empty() && std::isspace(header.back())) { + header.remove_suffix(1); + } + + // Only add non-empty headers + if (!header.empty()) { + headers.push_back(header); + } + } + + // Create Set and add headers + JS::RootedObject set(cx, JS::NewSetObject(cx)); + if (!set) { + return false; + } + + for (const auto &header : headers) { + JS::RootedString str(cx, JS_NewStringCopyN(cx, header.data(), header.length())); + if (!str) { + return false; + } + JS::RootedValue val(cx, JS::StringValue(str)); + if (!JS::SetAdd(cx, set, val)) { + return false; + } + } + + args.rval().setObject(*set); + return true; +} + +bool Response::surrogateKeys_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto opts = cache_write_options(self); + if (!opts) { + args.rval().setUndefined(); + return true; + } + + JS::RootedObject set(cx, JS::NewSetObject(cx)); + if (!set) { + return false; + } + + for (const auto &key : opts->surrogate_keys) { + JS::RootedString str(cx, JS_NewStringCopyN(cx, key.data(), key.size())); + if (!str) { + return false; + } + JS::RootedValue val(cx, JS::StringValue(str)); + if (!JS::SetAdd(cx, set, val)) { + return false; + } + } + + args.rval().setObject(*set); + return true; +} + +bool Response::pci_get(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(0) + + auto opts = cache_write_options(self); + if (!opts) { + args.rval().setUndefined(); + return true; + } + + args.rval().setBoolean(opts->sensitive_data); + return true; +} + +// Setters for mutable properties + +bool Response::ttl_set(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(1) + + auto opts = cache_write_options(self); + if (!opts) { + JS_ReportErrorLatin1(cx, "Cannot set TTL on non-cached response"); + return false; + } + + double seconds; + if (!JS::ToNumber(cx, args[0], &seconds)) { + return false; + } + + opts->max_age_ns = static_cast(seconds * 1e9); + + args.rval().setUndefined(); + return true; +} + +bool Response::swr_set(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(1) + + auto opts = cache_write_options(self); + if (!opts) { + JS_ReportErrorLatin1(cx, "Cannot set stale-while-revalidate on non-cached response"); + return false; + } + + double seconds; + if (!JS::ToNumber(cx, args[0], &seconds)) { + return false; + } + + opts->stale_while_revalidate_ns = static_cast(seconds * 1e9); + + args.rval().setUndefined(); + return true; +} + +// TODO: Convert from Set to Array +bool Response::vary_set(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(1) + + auto opts = cache_write_options(self); + if (!opts) { + JS_ReportErrorLatin1(cx, "Cannot set vary on non-cached response"); + return false; + } + + JS::RootedObject set_obj(cx); + bool is_set = false; + if (args[0].isObject()) { + set_obj.set(&args[0].toObject()); + if (!JS::IsSetObject(cx, set_obj, &is_set)) { + return false; + } + } + if (!is_set) { + JS_ReportErrorLatin1(cx, "vary must be a Set of strings"); + return false; + } + + JS::RootedValue set_vals(cx); + if (!JS::SetValues(cx, set_obj, &set_vals)) { + return false; + } + + MOZ_ASSERT(set_vals.isObject() && JS_IsTypedArrayObject(&set_vals.toObject())); + + JS::RootedObject set_vals_arr(cx, &set_vals.toObject()); + uint32_t length; + if (!JS::GetArrayLength(cx, set_vals_arr, &length)) { + return false; + } + + std::string vary_rule; + for (uint32_t i = 0; i < length; i++) { + JS::RootedValue val(cx); + if (!JS_GetElement(cx, set_vals_arr, i, &val)) { + return false; + } + if (!val.isString()) { + JS_ReportErrorLatin1(cx, "vary must be a Set of strings"); + return false; + } + auto str_val = core::encode(cx, val); + if (!str_val) { + return false; + } + if (!vary_rule.empty()) { + vary_rule += " "; + } + vary_rule.append(str_val.ptr.get(), str_val.len); + } + + opts->vary_rule = std::move(vary_rule); + + args.rval().setUndefined(); + return true; +} + +// TODO: Convert from Set to Array +bool Response::surrogateKeys_set(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(1) + + auto opts = cache_write_options(self); + if (!opts) { + JS_ReportErrorLatin1(cx, "Cannot set surrogate keys on non-cached response"); + return false; + } + + JS::RootedObject set_obj(cx); + bool is_set = false; + if (args[0].isObject()) { + set_obj.set(&args[0].toObject()); + if (!JS::IsSetObject(cx, set_obj, &is_set)) { + return false; + } + } + if (!is_set) { + JS_ReportErrorLatin1(cx, "surrogateKeys must be a Set of strings"); + return false; + } + + JS::RootedObject set(cx, &args[0].toObject()); + JS::RootedValue set_values(cx); + if (!JS::SetValues(cx, set, &set_values)) { + return false; + } + + JS::RootedObject values_array(cx, &set_values.toObject()); + uint32_t length; + if (!JS::GetArrayLength(cx, values_array, &length)) { + return false; + } + + std::vector keys; + std::vector key_storage; // Keep strings alive + keys.reserve(length); + key_storage.reserve(length); + + for (uint32_t i = 0; i < length; i++) { + JS::RootedValue val(cx); + if (!JS_GetElement(cx, values_array, i, &val)) { + return false; + } + if (!val.isString()) { + JS_ReportErrorLatin1(cx, "surrogateKeys must be a Set of strings"); + return false; + } + auto key = core::encode(cx, val); + if (!key) { + return false; + } + keys.emplace_back(key.ptr.get(), key.len); + key_storage.push_back(std::move(key.ptr)); + } + + opts->surrogate_keys = std::move(keys); + + args.rval().setUndefined(); + return true; +} + +bool Response::pci_set(JSContext *cx, unsigned argc, JS::Value *vp) { + METHOD_HEADER(1) + + auto opts = cache_write_options(self); + if (!opts) { + JS_ReportErrorLatin1(cx, "Cannot set PCI flag on non-cached response"); + return false; + } + opts->sensitive_data = JS::ToBoolean(args[0]); + args.rval().setUndefined(); + return true; +} + /** * The `Response` constructor https://fetch.spec.whatwg.org/#dom-response */ @@ -3198,6 +3617,9 @@ JSObject *Response::create(JSContext *cx, JS::HandleObject response, JS::BooleanValue(is_upstream)); JS::SetReservedSlot(response, static_cast(Slots::IsGripUpgrade), JS::BooleanValue(is_grip)); + JS::SetReservedSlot(response, static_cast(Slots::StorageAction), JS::UndefinedValue()); + JS::SetReservedSlot(response, static_cast(Slots::CacheWriteOptions), + JS::UndefinedValue()); if (backend) { JS::SetReservedSlot(response, static_cast(Slots::Backend), JS::StringValue(backend)); } diff --git a/runtime/fastly/builtins/fetch/request-response.h b/runtime/fastly/builtins/fetch/request-response.h index eb522468a3..0a15f02548 100644 --- a/runtime/fastly/builtins/fetch/request-response.h +++ b/runtime/fastly/builtins/fetch/request-response.h @@ -159,6 +159,7 @@ class Request final : public builtins::BuiltinImpl { static bool apply_cache_override(JSContext *cx, JS::HandleObject self); static bool apply_auto_decompress_gzip(JSContext *cx, JS::HandleObject self); + static bool isCacheable_get(JSContext *cx, unsigned argc, JS::Value *vp); static host_api::HttpReq request_handle(JSObject *obj); static host_api::HttpPendingReq pending_handle(JSObject *obj); static bool is_downstream(JSObject *obj); @@ -223,6 +224,9 @@ class Response final : public builtins::BuiltinImpl { StatusMessage, Redirected, IsGripUpgrade, + CacheEntry, + StorageAction, + CacheWriteOptions, Count, }; static const JSFunctionSpec static_methods[]; @@ -239,18 +243,41 @@ class Response final : public builtins::BuiltinImpl { host_api::HttpResp response_handle, host_api::HttpBody body_handle, bool is_upstream, bool is_grip_upgrade, JS::HandleString backend); + static host_api::HttpResp response_handle(JSObject *obj); + /** * Returns the RequestOrResponse's Headers, reifying it if necessary. */ static JSObject *headers(JSContext *cx, JS::HandleObject obj); - static host_api::HttpResp response_handle(JSObject *obj); + /** + * Helper method to get the cache entry for a response (if any) + */ + static host_api::HttpCacheEntry cache_entry(JSObject *obj); + static host_api::HttpStorageAction storage_action(JSObject *obj); + static host_api::HttpCacheWriteOptions *cache_write_options(JSObject *obj); + static bool is_upstream(JSObject *obj); static bool is_grip_upgrade(JSObject *obj); static host_api::HostString backend_str(JSContext *cx, JSObject *obj); static uint16_t status(JSObject *obj); static JSString *status_message(JSObject *obj); static void set_status_message_from_code(JSContext *cx, JSObject *obj, uint16_t code); + + static bool isCacheable_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool cached_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool isStale_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool ttl_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool ttl_set(JSContext *cx, unsigned argc, JS::Value *vp); + static bool age_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool swr_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool swr_set(JSContext *cx, unsigned argc, JS::Value *vp); + static bool vary_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool vary_set(JSContext *cx, unsigned argc, JS::Value *vp); + static bool surrogateKeys_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool surrogateKeys_set(JSContext *cx, unsigned argc, JS::Value *vp); + static bool pci_get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool pci_set(JSContext *cx, unsigned argc, JS::Value *vp); }; } // namespace fastly::fetch diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index 8676afbf17..b430440ae8 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -1802,10 +1802,10 @@ from_fastly_cache_write_options(const fastly::fastly_http_cache_write_options &f while (pos < keys_str.size()) { size_t space = keys_str.find(' ', pos); if (space == std::string_view::npos) { - opts.surrogate_keys.push_back(keys_str.substr(pos)); + opts.surrogate_keys.push_back(std::string(keys_str.substr(pos))); break; } - opts.surrogate_keys.push_back(keys_str.substr(pos, space - pos)); + opts.surrogate_keys.push_back(std::string(keys_str.substr(pos, space - pos))); pos = space + 1; } } @@ -2009,19 +2009,20 @@ HttpCacheEntry::get_suggested_cache_options(const HttpResp &resp) const { from_fastly_cache_write_options(options_out, options_mask_out)); } -Result> +Result> HttpCacheEntry::prepare_response_for_storage(HttpResp resp) const { - uint8_t storage_action_out; + HttpStorageAction storage_action_out; uint32_t updated_resp_handle_out; auto res = fastly::http_cache_prepare_response_for_storage( - this->handle, resp.handle, &storage_action_out, &updated_resp_handle_out); + this->handle, resp.handle, reinterpret_cast(&storage_action_out), + &updated_resp_handle_out); if (res != 0) { - return Result>::err(host_api::APIError(res)); + return Result>::err(host_api::APIError(res)); } - return Result>::ok( + return Result>::ok( std::make_tuple(storage_action_out, HttpResp(updated_resp_handle_out))); } diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h index 65047cf77a..3be3ed43f1 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -643,7 +643,7 @@ struct HttpCacheWriteOptions final { uint64_t max_age_ns; // Optional vary rule - header names separated by spaces - std::optional vary_rule; + std::optional vary_rule; // Optional initial age of the response in nanoseconds std::optional initial_age_ns; @@ -652,7 +652,7 @@ struct HttpCacheWriteOptions final { std::optional stale_while_revalidate_ns; // Optional surrogate keys separated by spaces - std::vector surrogate_keys; + std::vector surrogate_keys; // Optional length of the response body std::optional length; @@ -673,6 +673,13 @@ struct CacheState final { bool must_insert_or_update() const; }; +enum class HttpStorageAction : uint8_t { + Insert = 0, + Update = 1, + DoNotStore = 2, + RecordUncacheable = 3 +}; + class HttpCacheEntry final { public: using Handle = uint32_t; @@ -723,7 +730,7 @@ class HttpCacheEntry final { Result get_suggested_cache_options(const HttpResp &resp) const; /// Prepare response for storage - Result> prepare_response_for_storage(HttpResp resp) const; + Result> prepare_response_for_storage(HttpResp resp) const; /// Get found response Result> get_found_response(bool transform_for_client = true) const; diff --git a/types/globals.d.ts b/types/globals.d.ts index 387cf4bc85..00bcfb83b7 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -1352,10 +1352,6 @@ interface Response extends Body { * (that awaited that response) will show as hits. */ readonly cached: boolean; - /** - * Fastly-specific property - If this was a backend response, whether it should be cached - */ - readonly isCacheable: boolean; /** * Fastly-specific property - Returns whether the cached `Response` is considered stale. * @@ -1389,13 +1385,13 @@ interface Response extends Body { * * Undefined if the response is not cached. May be modified prior to injection into the cache. */ - vary: Set | undefined; + vary: Array | undefined; /** * Fastly-specific property - The surrogate keys for the cached response. * * Undefined if the response is not cached. May be modified prior to injection into the cache. */ - surrogateKeys: Set | undefined; + surrogateKeys: Array | undefined; /** * Fastly-specific property - Get or set whether this response should only be stored via PCI/HIPAA-compliant non-volatile caching. * @@ -1416,6 +1412,10 @@ declare var Response: { // error(): Response; redirect(url: string | URL, status?: number): Response; json(data: any, init?: ResponseInit): Response; + /** + * Fastly-specific property - If this was a backend response, whether it should be cached + */ + readonly isCacheable: boolean; }; /**