From e993c641885eacdf66feafbe0a2a828136984ad4 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 31 Dec 2024 13:11:30 -0800 Subject: [PATCH] initial implementation --- .../fixtures/app/src/cache-override.js | 30 +- .../js-compute/fixtures/app/src/index.js | 2 - .../js-compute/fixtures/app/tests.json | 74 ----- .../{app => module-mode}/src/console.js | 0 .../{app => module-mode}/src/http-cache.js | 19 +- .../fixtures/module-mode/src/index.js | 2 + .../fixtures/module-mode/tests.json | 73 +++++ runtime/fastly/builtins/cache-override.cpp | 21 +- runtime/fastly/builtins/fetch/fetch.cpp | 295 +++++++++++++++--- .../builtins/fetch/request-response.cpp | 133 ++++++-- .../fastly/builtins/fetch/request-response.h | 6 + runtime/fastly/host-api/fastly.h | 4 + runtime/fastly/host-api/host_api.cpp | 53 +++- runtime/fastly/host-api/host_api_fastly.h | 15 +- runtime/fastly/host-api/host_call.cpp | 2 + types/cache-override.d.ts | 10 + 16 files changed, 559 insertions(+), 180 deletions(-) rename integration-tests/js-compute/fixtures/{app => module-mode}/src/console.js (100%) rename integration-tests/js-compute/fixtures/{app => module-mode}/src/http-cache.js (97%) diff --git a/integration-tests/js-compute/fixtures/app/src/cache-override.js b/integration-tests/js-compute/fixtures/app/src/cache-override.js index d853de4ba9..46f72d4a08 100644 --- a/integration-tests/js-compute/fixtures/app/src/cache-override.js +++ b/integration-tests/js-compute/fixtures/app/src/cache-override.js @@ -16,33 +16,6 @@ import { isRunningLocally, routes } from './routes.js'; ); }, ); - // https://tc39.es/ecma262/#sec-tostring - routes.set( - '/cache-override/constructor/parameter-calls-7.1.17-ToString', - async () => { - let sentinel; - const test = () => { - sentinel = Symbol(); - const name = { - toString() { - throw sentinel; - }, - }; - new CacheOverride(name); - }; - assertThrows(test); - try { - test(); - } catch (thrownError) { - assert(thrownError, sentinel, 'thrownError === sentinel'); - } - assertThrows( - () => new CacheOverride(Symbol()), - TypeError, - `can't convert symbol to string`, - ); - }, - ); routes.set('/cache-override/constructor/empty-parameter', async () => { assertThrows( () => { @@ -80,6 +53,9 @@ import { isRunningLocally, routes } from './routes.js'; assertDoesNotThrow(() => { new CacheOverride('override', {}); }); + assertDoesNotThrow(() => { + new CacheOverride({}); + }); }); } // Using CacheOverride diff --git a/integration-tests/js-compute/fixtures/app/src/index.js b/integration-tests/js-compute/fixtures/app/src/index.js index 8c3ee0bd46..c8b124d20b 100644 --- a/integration-tests/js-compute/fixtures/app/src/index.js +++ b/integration-tests/js-compute/fixtures/app/src/index.js @@ -14,7 +14,6 @@ import './cache-simple.js'; import './client.js'; import './compute.js'; import './config-store.js'; -import './console.js'; import './crypto.js'; import './device.js'; import './dictionary.js'; @@ -25,7 +24,6 @@ import './fastly-global.js'; import './fetch-errors.js'; import './geoip.js'; import './headers.js'; -import './http-cache.js'; import './include-bytes.js'; import './logger.js'; import './manual-framing-headers.js'; diff --git a/integration-tests/js-compute/fixtures/app/tests.json b/integration-tests/js-compute/fixtures/app/tests.json index 63601a8b14..b0a0496dd0 100644 --- a/integration-tests/js-compute/fixtures/app/tests.json +++ b/integration-tests/js-compute/fixtures/app/tests.json @@ -29,7 +29,6 @@ } }, "GET /cache-override/constructor/called-as-regular-function": {}, - "GET /cache-override/constructor/parameter-calls-7.1.17-ToString": {}, "GET /cache-override/constructor/empty-parameter": {}, "GET /cache-override/constructor/invalid-mode": {}, "GET /cache-override/constructor/valid-mode": {}, @@ -317,53 +316,6 @@ "GET /client/tlsCipherOpensslName": {}, "GET /client/tlsProtocol": {}, "GET /config-store": {}, - "GET /console": { - "environments": ["viceroy"], - "logs": [ - "stdout :: Log: Happy birthday Aki and Yuki!", - "stdout :: Log: Map: Map(2) { { a: 1, b: { c: 2 } } => 2, [ function foo() {\n }] => {} }", - "stdout :: Log: Set: Set(3) { { a: 1, b: { c: 2 } }, 2, 3 }", - "stdout :: Log: Array: [1, 2, 3, [], 5]", - "stdout :: Log: Object: { a: 1, b: 2, c: 3, d: [ d() {\n }], f: [Getter], g: [ function bar() {\n}], h: [ function from() {\n[native code]\n}] }", - "stdout :: Log: function: [ function() {\n }]", - "stdout :: Log: boolean: true", - "stdout :: Log: undefined: undefined", - "stdout :: Log: null: null", - "stdout :: Log: proxy: { a: 21 }", - "stdout :: Log: Infinity: Infinity", - "stdout :: Log: NaN: NaN", - "stdout :: Log: Symbol: Symbol(\"wow\")", - "stdout :: Log: Error: (new Error(\"uh oh\", \"\", 7644))", - "stdout :: Log: Number: 1", - "stdout :: Log: Number: 1.111", - "stdout :: Log: BigInt: 10n", - "stdout :: Log: Date: new Date(1660816667120)", - "stdout :: Log: string: cake", - "stdout :: Log: RegExp: /magic/", - "stdout :: Log: Int8Array: Int8Array [1, 3, 4, 2, 5, 6, -97]", - "stdout :: Log: Uint8Array: Uint8Array [1, 3, 4, 2, 5, 6, 159]", - "stdout :: Log: Uint8ClampedArray: Uint8ClampedArray [1, 3, 4, 2, 5, 6, 255]", - "stdout :: Log: Int16Array: Int16Array [1, 3, 4, 2, 5, 6, -31073]", - "stdout :: Log: Uint16Array: Uint16Array [1, 3, 4, 2, 5, 6, 34463]", - "stdout :: Log: Int32Array: Int32Array [1, 3, 4, 2, 5, 6, 99999]", - "stdout :: Log: Uint32Array: Uint32Array [1, 3, 4, 2, 5, 6, 99999]", - "stdout :: Log: Float32Array: Float32Array [1, 3, 4, 2, 5, 6, 99999]", - "stdout :: Log: Float64Array: Float64Array [1, 3, 4, 2, 5, 6, 99999]", - "stdout :: Log: BigInt64Array: BigInt64Array [1n, 3n, 4n, 2n, 5n, 6n, 99999n]", - "stdout :: Log: BigUint64Array: BigUint64Array [1n, 3n, 4n, 2n, 5n, 6n, 99999n]", - "stdout :: Log: WeakMap: WeakMap { }", - "stdout :: Log: WeakSet: WeakSet { }", - "stdout :: Log: Promise: Promise { }", - "stdout :: Log: resolved promise: Promise { 9 }", - "stdout :: Log: rejected promise: Promise { (new Error(\"oops\", \"\", 7689)) }", - "stdout :: Log: Response: Response { redirected: false, type: \"default\", url: \"\", status: 200, ok: true, statusText: \"\", version: 2, headers: Headers {}, body: ReadableStream { locked: false }, bodyUsed: false }", - "stdout :: Log: Request: Request { method: \"POST\", url: \"https://www.fastly.com/\", version: 2, headers: Headers {}, backend: undefined, body: null, bodyUsed: false }", - "stdout :: Log: ReadableStream: ReadableStream { locked: false }", - "stdout :: Log: TransformStream: TransformStream { readable: ReadableStream { locked: false }, writable: WritableStream {} }", - "stdout :: Log: WritableStream: WritableStream {}", - "stdout :: Log: URL: URL { hash: \"\", host: \"www.test.com:123\", hostname: \"www.test.com\", href: \"https://www.test.com:123/asdf?some¶ms=val\", origin: \"https://www.test.com:123\", password: \"\", pathname: \"/asdf\", port: \"123\", protocol: \"https:\", search: \"?some¶ms=val\", searchParams: URLSearchParams {}, username: \"\" }" - ] - }, "GET /crypto": { "downstream_response": { "status": 200, @@ -1332,32 +1284,6 @@ "headers": [["cuStom", "test"]] } }, - "GET /http-cache/invalid-properties": {}, - "GET /http-cache/invalid-transform": {}, - "GET /http-cache/hook-errors": {}, - "GET /http-cache/readonly-properties": {}, - "GET /http-cache/property-errors": {}, - "GET /http-cache/property-access-errors": {}, - "GET /http-cache/after-send-edge-cache": {}, - "GET /http-cache/after-send-browser-cache": {}, - "GET /http-cache/before-send": {}, - "GET /http-cache/request-mutation": {}, - "GET /http-cache/request-mutation-order": {}, - "GET /http-cache/response-mutations": {}, - "GET /http-cache/cacheability": {}, - "GET /http-cache/stale-responses": {}, - "GET /http-cache/body-transform": {}, - "GET /http-cache/body-transform-error": {}, - "GET /http-cache/body-transform-invalid-chunk": {}, - "GET /http-cache/body-transform-write-after-close": {}, - "GET /http-cache/body-transform-cancel": {}, - "GET /http-cache/body-transform-backpressure": {}, - "GET /http-cache/request-collapsing-options": {}, - "GET /http-cache/request-collapsing-uncacheable": {}, - "GET /http-cache/request-collapsing-vary": {}, - "GET /http-cache/concurrent-modifications": {}, - "GET /http-cache/concurrent-transforms": {}, - "GET /http-cache/revalidation-updates": {}, "GET /FastlyBody/interface": { "environments": ["compute"], "downstream_response": { diff --git a/integration-tests/js-compute/fixtures/app/src/console.js b/integration-tests/js-compute/fixtures/module-mode/src/console.js similarity index 100% rename from integration-tests/js-compute/fixtures/app/src/console.js rename to integration-tests/js-compute/fixtures/module-mode/src/console.js diff --git a/integration-tests/js-compute/fixtures/app/src/http-cache.js b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js similarity index 97% rename from integration-tests/js-compute/fixtures/app/src/http-cache.js rename to integration-tests/js-compute/fixtures/module-mode/src/http-cache.js index c0ebf7eaaa..584bfd00e7 100644 --- a/integration-tests/js-compute/fixtures/app/src/http-cache.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/http-cache.js @@ -1,8 +1,7 @@ /* eslint-env serviceworker */ import { strictEqual, assertRejects } from './assertions.js'; import { routes } from './routes.js'; -import { allowDynamicBackends } from 'fastly:experimental'; -allowDynamicBackends(true); +import { CacheOverride } from 'fastly:cache-override'; // generate a unique URL everytime so that we never work on a populated cache const getTestUrl = () => @@ -283,6 +282,20 @@ const getTestUrl = () => strictEqual(res.headers.get('Cache-Control'), 'max-age=3600'); }); + routes.set('/http-cache/after-send-res-no-body-error', async () => { + let afterSendRes; + const cacheOverride = new CacheOverride({ + afterSend(res) { + afterSendRes = res; + }, + }); + await fetch(url, { cacheOverride }); + strictEqual(typeof afterSendRes, 'object'); + // this should throw -> reading a body of a candidate response is not supported + // since revalidations have no body + return res; + }); + routes.set('/http-cache/before-send', async () => { let calledBeforeSend = false; const cacheOverride = new CacheOverride({ @@ -854,4 +867,4 @@ const getTestUrl = () => // Testing TODO: // - new properties -// - body transform +// - body transform (and not being called for revalidations) diff --git a/integration-tests/js-compute/fixtures/module-mode/src/index.js b/integration-tests/js-compute/fixtures/module-mode/src/index.js index c183df8397..5580cef0e8 100644 --- a/integration-tests/js-compute/fixtures/module-mode/src/index.js +++ b/integration-tests/js-compute/fixtures/module-mode/src/index.js @@ -4,9 +4,11 @@ import { routes } from './routes.js'; import { env } from 'fastly:env'; +import './console.js'; import './dynamic-backend.js'; import './hello-world.js'; import './hono.js'; +import './http-cache.js'; import './kv-store.js'; addEventListener('fetch', (event) => { diff --git a/integration-tests/js-compute/fixtures/module-mode/tests.json b/integration-tests/js-compute/fixtures/module-mode/tests.json index dad32f90a6..8ed5200153 100644 --- a/integration-tests/js-compute/fixtures/module-mode/tests.json +++ b/integration-tests/js-compute/fixtures/module-mode/tests.json @@ -90,6 +90,53 @@ "GET /backend/port-ip-defined": {}, "GET /backend/port-ip-cached": {}, "GET /backend/props": {}, + "GET /console": { + "environments": ["viceroy"], + "logs": [ + "stdout :: Log: Happy birthday Aki and Yuki!", + "stdout :: Log: Map: Map(2) { { a: 1, b: { c: 2 } } => 2, [ function foo() {\n }] => {} }", + "stdout :: Log: Set: Set(3) { { a: 1, b: { c: 2 } }, 2, 3 }", + "stdout :: Log: Array: [1, 2, 3, [], 5]", + "stdout :: Log: Object: { a: 1, b: 2, c: 3, d: [ d() {\n }], f: [Getter], g: [ function bar() {\n}], h: [ function from() {\n[native code]\n}] }", + "stdout :: Log: function: [ function() {\n }]", + "stdout :: Log: boolean: true", + "stdout :: Log: undefined: undefined", + "stdout :: Log: null: null", + "stdout :: Log: proxy: { a: 21 }", + "stdout :: Log: Infinity: Infinity", + "stdout :: Log: NaN: NaN", + "stdout :: Log: Symbol: Symbol(\"wow\")", + "stdout :: Log: Error: (new Error(\"uh oh\", \"\", 7644))", + "stdout :: Log: Number: 1", + "stdout :: Log: Number: 1.111", + "stdout :: Log: BigInt: 10n", + "stdout :: Log: Date: new Date(1660816667120)", + "stdout :: Log: string: cake", + "stdout :: Log: RegExp: /magic/", + "stdout :: Log: Int8Array: Int8Array [1, 3, 4, 2, 5, 6, -97]", + "stdout :: Log: Uint8Array: Uint8Array [1, 3, 4, 2, 5, 6, 159]", + "stdout :: Log: Uint8ClampedArray: Uint8ClampedArray [1, 3, 4, 2, 5, 6, 255]", + "stdout :: Log: Int16Array: Int16Array [1, 3, 4, 2, 5, 6, -31073]", + "stdout :: Log: Uint16Array: Uint16Array [1, 3, 4, 2, 5, 6, 34463]", + "stdout :: Log: Int32Array: Int32Array [1, 3, 4, 2, 5, 6, 99999]", + "stdout :: Log: Uint32Array: Uint32Array [1, 3, 4, 2, 5, 6, 99999]", + "stdout :: Log: Float32Array: Float32Array [1, 3, 4, 2, 5, 6, 99999]", + "stdout :: Log: Float64Array: Float64Array [1, 3, 4, 2, 5, 6, 99999]", + "stdout :: Log: BigInt64Array: BigInt64Array [1n, 3n, 4n, 2n, 5n, 6n, 99999n]", + "stdout :: Log: BigUint64Array: BigUint64Array [1n, 3n, 4n, 2n, 5n, 6n, 99999n]", + "stdout :: Log: WeakMap: WeakMap { }", + "stdout :: Log: WeakSet: WeakSet { }", + "stdout :: Log: Promise: Promise { }", + "stdout :: Log: resolved promise: Promise { 9 }", + "stdout :: Log: rejected promise: Promise { (new Error(\"oops\", \"\", 7689)) }", + "stdout :: Log: Response: Response { redirected: false, type: \"default\", url: \"\", status: 200, ok: true, statusText: \"\", version: 2, headers: Headers {}, body: ReadableStream { locked: false }, bodyUsed: false }", + "stdout :: Log: Request: Request { method: \"POST\", url: \"https://www.fastly.com/\", version: 2, headers: Headers {}, backend: undefined, body: null, bodyUsed: false }", + "stdout :: Log: ReadableStream: ReadableStream { locked: false }", + "stdout :: Log: TransformStream: TransformStream { readable: ReadableStream { locked: false }, writable: WritableStream {} }", + "stdout :: Log: WritableStream: WritableStream {}", + "stdout :: Log: URL: URL { hash: \"\", host: \"www.test.com:123\", hostname: \"www.test.com\", href: \"https://www.test.com:123/asdf?some¶ms=val\", origin: \"https://www.test.com:123\", password: \"\", pathname: \"/asdf\", port: \"123\", protocol: \"https:\", search: \"?some¶ms=val\", searchParams: URLSearchParams {}, username: \"\" }" + ] + }, "GET /hello-world": { "downstream_response": { "status": 200, @@ -102,6 +149,32 @@ "body_prefix": "{\n \"args\": {}," } }, + "GET /http-cache/invalid-properties": {}, + "GET /http-cache/invalid-transform": {}, + "GET /http-cache/hook-errors": {}, + "GET /http-cache/readonly-properties": {}, + "GET /http-cache/property-errors": {}, + "GET /http-cache/property-access-errors": {}, + "GET /http-cache/after-send-edge-cache": {}, + "GET /http-cache/after-send-browser-cache": {}, + "GET /http-cache/before-send": {}, + "GET /http-cache/request-mutation": {}, + "GET /http-cache/request-mutation-order": {}, + "GET /http-cache/response-mutations": {}, + "GET /http-cache/cacheability": {}, + "GET /http-cache/stale-responses": {}, + "GET /http-cache/body-transform": {}, + "GET /http-cache/body-transform-error": {}, + "GET /http-cache/body-transform-invalid-chunk": {}, + "GET /http-cache/body-transform-write-after-close": {}, + "GET /http-cache/body-transform-cancel": {}, + "GET /http-cache/body-transform-backpressure": {}, + "GET /http-cache/request-collapsing-options": {}, + "GET /http-cache/request-collapsing-uncacheable": {}, + "GET /http-cache/request-collapsing-vary": {}, + "GET /http-cache/concurrent-modifications": {}, + "GET /http-cache/concurrent-transforms": {}, + "GET /http-cache/revalidation-updates": {}, "GET /kv-store-e2e/list": {}, "GET /kv-store/exposed-as-global": {}, "GET /kv-store/interface": {}, diff --git a/runtime/fastly/builtins/cache-override.cpp b/runtime/fastly/builtins/cache-override.cpp index af1779515d..d62d3b2999 100644 --- a/runtime/fastly/builtins/cache-override.cpp +++ b/runtime/fastly/builtins/cache-override.cpp @@ -164,7 +164,7 @@ bool CacheOverride::ensure_override(JSContext *cx, JS::HandleObject self, const } bool CacheOverride::mode_set(JSContext *cx, JS::HandleObject self, JS::HandleValue val, - JS::MutableHandleValue rval) { + JS::MutableHandleValue ret) { if (self == proto_obj) { return api::throw_error(cx, api::Errors::WrongReceiver, "mode get", "CacheOverride"); } @@ -399,17 +399,27 @@ bool CacheOverride::constructor(JSContext *cx, unsigned argc, JS::Value *vp) { JS::RootedObject self(cx, JS_NewObjectForConstructor(cx, &class_, args)); JS::RootedValue val(cx); - if (!mode_set(cx, self, args[0], &val)) - return false; + + JS::RootedValue init(cx); + if (args[0].isObject()) { + init.setObject(args[0].toObject()); + CacheOverride::set_mode(self, CacheOverrideMode::Override); + } else { + if (!mode_set(cx, self, args[0], &val)) + return false; + if (args.length() > 1) { + init.set(args[1]); + } + } if (CacheOverride::mode(self) == CacheOverride::CacheOverrideMode::Override) { - if (!args.get(1).isObject()) { + if (!init.isObject()) { JS_ReportErrorUTF8(cx, "Creating a CacheOverride object with mode \"override\" requires " "an init object for the override parameters as the second argument"); return false; } - JS::RootedObject override_init(cx, &args[1].toObject()); + JS::RootedObject override_init(cx, &init.toObject()); if (!JS_GetProperty(cx, override_init, "ttl", &val) || !ttl_set(cx, self, val, &val)) { return false; @@ -457,7 +467,6 @@ JSObject *CacheOverride::clone(JSContext *cx, JS::HandleObject self) { for (size_t i = 0; i < Slots::Count; i++) { JS::Value val = JS::GetReservedSlot(self, i); - MOZ_ASSERT(!val.isObject()); JS::SetReservedSlot(result, i, val); } diff --git a/runtime/fastly/builtins/fetch/fetch.cpp b/runtime/fastly/builtins/fetch/fetch.cpp index 90d74c06a3..b3be1426cc 100644 --- a/runtime/fastly/builtins/fetch/fetch.cpp +++ b/runtime/fastly/builtins/fetch/fetch.cpp @@ -1,18 +1,19 @@ #include "fetch.h" #include "../../../StarlingMonkey/builtins/web/fetch/headers.h" #include "../backend.h" +#include "../cache-override.h" #include "../fastly.h" #include "../fetch-event.h" #include "./request-response.h" #include "encode.h" #include "extension-api.h" +using fastly::FastlyGetErrorMessage; using fastly::backend::Backend; +using fastly::cache_override::CacheOverride; using fastly::fastly::Fastly; using fastly::fetch::Request; -using fastly::FastlyGetErrorMessage; - namespace fastly::fetch { api::Engine *ENGINE; @@ -47,6 +48,257 @@ class FetchTask final : public api::AsyncTask { } }; +bool must_use_guest_caching(JSContext *cx, HandleObject request) { + RootedValue cache_override_val( + cx, JS::GetReservedSlot(request, static_cast(Request::Slots::CacheOverride))); + if (!cache_override_val.isUndefined()) { + RootedObject cache_override(cx, &cache_override_val.toObject()); + return !CacheOverride::beforeSend(cache_override).isUndefined() || + !CacheOverride::afterSend(cache_override).isUndefined(); + } + return false; +} + +bool should_use_guest_caching(JSContext *cx, HandleObject request, bool *should_use_cache) { + *should_use_cache = true; + + // Check for pass cache override + RootedValue cache_override_val( + cx, JS::GetReservedSlot(request, static_cast(Request::Slots::CacheOverride))); + if (!cache_override_val.isUndefined()) { + RootedObject cache_override(cx, &cache_override_val.toObject()); + if (CacheOverride::mode(cache_override) == CacheOverride::CacheOverrideMode::Pass) { + // Pass requests have to go through the host for now + *should_use_cache = false; + return true; + } + } + + // Check for PURGE method + RootedString method_str(cx, Request::method(cx, request)); + bool is_purge = false; + if (method_str && !JS_StringEqualsLiteral(cx, method_str, "PURGE", &is_purge)) { + return false; + } + if (is_purge) { + // We don't yet implement guest-side URL purges + *should_use_cache = false; + return true; + } + + // Check if we must use host caching by checking if guest caching is unsupported + auto request_handle = Request::request_handle(request); + auto res = request_handle.is_cacheable(); + if (auto *err = res.to_err()) { + if (host_api::error_is_unsupported(*err)) { + // Guest-side caching is unsupported, so we must use host caching. + // If we have hooks we must fail since they require guest caching. + if (must_use_guest_caching(cx, request)) { + JS_ReportErrorASCII(cx, "HTTP caching API is not enabled; please contact support for help"); + return false; + } + *should_use_cache = false; + return true; + } + HANDLE_ERROR(cx, *err); + return false; + } + + return true; +} + +// Legacy fetch send without HTTP cache API +template +bool fetch_send_body(JSContext *cx, HandleObject request, host_api::HostString &backend_chars, + JS::MutableHandleValue ret) { + RootedObject response_promise(cx, JS::NewPromiseObject(cx, nullptr)); + if (!response_promise) { + return false; + } + + bool streaming = false; + if (!RequestOrResponse::maybe_stream_body(cx, request, &streaming)) { + return false; + } + + host_api::HttpPendingReq pending_handle; + { + auto request_handle = Request::request_handle(request); + auto body = RequestOrResponse::body_handle(request); + auto res = with_caching + ? streaming ? request_handle.send_async_streaming(body, backend_chars) + : request_handle.send_async(body, backend_chars) + : request_handle.send_async_without_caching(body, backend_chars, streaming); + + if (auto *err = res.to_err()) { + if (host_api::error_is_generic(*err) || host_api::error_is_invalid_argument(*err)) { + JS_ReportErrorNumberASCII(cx, FastlyGetErrorMessage, nullptr, + JSMSG_REQUEST_BACKEND_DOES_NOT_EXIST, backend_chars.ptr.get()); + } else { + HANDLE_ERROR(cx, *err); + } + ret.setObject(*PromiseRejectedWithPendingError(cx)); + return true; + } + + pending_handle = res.unwrap(); + } + + // If the request body is streamed, we need to wait for streaming to complete before marking the + // request as pending. + if (!streaming) { + ENGINE->queue_async_task(new FetchTask(pending_handle.handle, request, response_promise)); + } + + JS::SetReservedSlot(request, static_cast(Request::Slots::PendingRequest), + JS::Int32Value(pending_handle.handle)); + JS::SetReservedSlot(request, static_cast(Request::Slots::ResponsePromise), + JS::ObjectValue(*response_promise)); + ret.setObject(*response_promise); + return true; +} + +bool fetch_send(JSContext *cx, HandleObject request, host_api::HostString &backend_chars, + JS::MutableHandleValue ret) { + // Determine if we should use guest-side caching + bool should_use_guest_caching_out; + if (!should_use_guest_caching(cx, request, &should_use_guest_caching_out)) { + return false; + } + if (!should_use_guest_caching_out) { + return fetch_send_body(cx, request, backend_chars, ret); + } + + // Check if request is actually cacheable + bool is_cacheable = false; + { + auto request_handle = Request::request_handle(request); + auto res = request_handle.is_cacheable(); + if (auto *err = res.to_err()) { + HANDLE_ERROR(cx, *err); + JSObject *promise = PromiseRejectedWithPendingError(cx); + if (!promise) { + return false; + } + ret.setObject(*promise); + return true; + } + is_cacheable = res.unwrap(); + } + + // If not cacheable, fallback to non-caching path + if (!is_cacheable) { + return fetch_send_body(cx, request, backend_chars, ret); + } + + // Get cache override key if set (TODO) + // RootedValue cache_key(cx, JS::GetReservedSlot(request, + // static_cast(Request::Slots::OverrideCacheKey)); JS::SetReservedSlot(request, + // static_cast(Request::Slots::OverrideCacheKey), JS::UndefinedValue()); + + // Lookup in cache + auto request_handle = Request::request_handle(request); + + // Convert cache key to span if present + std::span override_key_span = {}; + // host_api::HostString override_key_str; + // if (cache_key.isString()) { + // override_key_str = host_api::HostString(cx, cache_key.toString()); + // override_key_span = std::span(reinterpret_cast(override_key_str.data()), + // override_key_str.size()); + // } + + auto transaction_res = + host_api::HttpCacheEntry::transaction_lookup(request_handle, override_key_span); + if (auto *err = transaction_res.to_err()) { + if (host_api::error_is_limit_exceeded(*err)) { + JS_ReportErrorASCII(cx, "HTTP caching limit exceeded"); + } else { + HANDLE_ERROR(cx, *err); + } + JSObject *promise = PromiseRejectedWithPendingError(cx); + if (!promise) { + return false; + } + ret.setObject(*promise); + return true; + } + host_api::HttpCacheEntry cache_entry = transaction_res.unwrap(); + JS::SetReservedSlot(request, static_cast(Request::Slots::CacheHandle), + JS::Int32Value(cache_entry.handle)); + + auto state_res = cache_entry.get_state(); + if (auto *err = state_res.to_err()) { + HANDLE_ERROR(cx, *err); + JSObject *promise = PromiseRejectedWithPendingError(cx); + if (!promise) { + return false; + } + ret.setObject(*promise); + return true; + } + auto cache_state = state_res.unwrap(); + + // Check for usable cached response + auto found_res = cache_entry.get_found_response(true); + if (auto *err = found_res.to_err()) { + HANDLE_ERROR(cx, *err); + JSObject *promise = PromiseRejectedWithPendingError(cx); + if (!promise) { + return false; + } + ret.setObject(*promise); + return true; + } + + auto maybe_response = found_res.unwrap(); + if (maybe_response.has_value()) { + auto cached_response = maybe_response.value(); + + if (cache_state.must_insert_or_update()) { + // Need to start background revalidation + // Queue async task to handle background cache revalidation, ensuring it blocks process + // completion + JS_ReportErrorASCII(cx, "TODO: send_async_for_caching background cache revalidation"); + JSObject *promise = PromiseRejectedWithPendingError(cx); + if (!promise) { + return false; + } + ret.setObject(*promise); + return true; + } + + JS::RootedObject response(cx, Response::create(cx, request, cached_response)); + + // Return cached response regardless of revalidation status + if (!Response::add_fastly_cache_headers(cx, response, request, "cached response")) { + return false; + } + + RootedObject response_promise(cx, JS::NewPromiseObject(cx, nullptr)); + JS::RootedValue response_val(cx, JS::ObjectValue(*response)); + return JS::ResolvePromise(cx, response_promise, response_val); + } + + // No valid cached response, need to make backend request + if (!cache_state.must_insert_or_update()) { + // request collapsing has been disabled: pass the original request to the origin without + // updating the cache + return fetch_send_body(cx, request, backend_chars, ret); + } else { + JS_ReportErrorASCII(cx, "TODO: send_async_for_caching"); + JSObject *promise = PromiseRejectedWithPendingError(cx); + if (!promise) { + return false; + } + ret.setObject(*promise); + return true; + } + + return true; +} + // TODO: throw in all Request methods/getters that rely on host calls once a // request has been sent. The host won't let us act on them anymore anyway. /** @@ -110,10 +362,6 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { return ReturnPromiseRejectedWithPendingError(cx, args); } - RootedObject response_promise(cx, JS::NewPromiseObject(cx, nullptr)); - if (!response_promise) - return ReturnPromiseRejectedWithPendingError(cx, args); - if (!Request::apply_cache_override(cx, request)) { return false; } @@ -122,43 +370,10 @@ bool fetch(JSContext *cx, unsigned argc, Value *vp) { return false; } - bool streaming = false; - if (!RequestOrResponse::maybe_stream_body(cx, request, &streaming)) { + if (!fetch_send(cx, request, backend_chars, args.rval())) { return false; } - host_api::HttpPendingReq pending_handle; - { - auto request_handle = Request::request_handle(request); - auto body = RequestOrResponse::body_handle(request); - auto res = streaming ? request_handle.send_async_streaming(body, backend_chars) - : request_handle.send_async(body, backend_chars); - - if (auto *err = res.to_err()) { - if (host_api::error_is_generic(*err) || host_api::error_is_invalid_argument(*err)) { - JS_ReportErrorNumberASCII(cx, FastlyGetErrorMessage, nullptr, - JSMSG_REQUEST_BACKEND_DOES_NOT_EXIST, backend_chars.ptr.get()); - } else { - HANDLE_ERROR(cx, *err); - } - return ReturnPromiseRejectedWithPendingError(cx, args); - } - - pending_handle = res.unwrap(); - } - - // If the request body is streamed, we need to wait for streaming to complete before marking the - // request as pending. - if (!streaming) { - ENGINE->queue_async_task(new FetchTask(pending_handle.handle, request, response_promise)); - } - - JS::SetReservedSlot(request, static_cast(Request::Slots::PendingRequest), - JS::Int32Value(pending_handle.handle)); - JS::SetReservedSlot(request, static_cast(Request::Slots::ResponsePromise), - JS::ObjectValue(*response_promise)); - - args.rval().setObject(*response_promise); return true; } diff --git a/runtime/fastly/builtins/fetch/request-response.cpp b/runtime/fastly/builtins/fetch/request-response.cpp index 0d8dbf7edc..758ce2907f 100644 --- a/runtime/fastly/builtins/fetch/request-response.cpp +++ b/runtime/fastly/builtins/fetch/request-response.cpp @@ -190,6 +190,86 @@ ReadResult read_from_handle_all(JSContext *cx, host_api::HttpBody body) { } // namespace +bool Response::add_fastly_cache_headers(JSContext *cx, JS::HandleObject self, + JS::HandleObject request, const char *fun_name) { + MOZ_ASSERT(Response::is_instance(self)); + // Get response headers object + JSObject *headers = Response::headers(cx, self); + if (!headers) { + return false; + } + JS::RootedObject headers_val(cx, headers); + + // Get cache handle and hits + auto cache_entry_opt = Request::cache_handle(request); + if (cache_entry_opt) { + auto hits_res = cache_entry_opt->get_hits(); + if (auto *err = hits_res.to_err()) { + HANDLE_ERROR(cx, *err); + return false; + } + uint64_t hits = hits_res.unwrap(); + + // Add HIT headers + JS::RootedValue hit_str_val(cx, JS::StringValue(JS_NewStringCopyZ(cx, "HIT"))); + if (!Headers::append_valid_header(cx, headers_val, "x-cache", hit_str_val, fun_name)) { + return false; + } + + // Convert hits to string and add header + std::string hits_str = std::to_string(hits); + JS::RootedValue hits_str_val( + cx, JS::StringValue(JS_NewStringCopyN(cx, hits_str.c_str(), hits_str.length()))); + if (!Headers::append_valid_header(cx, headers_val, "x-cache-hits", hits_str_val, fun_name)) { + return false; + } + } else { + // Add MISS headers + JS::RootedValue miss_str_val(cx, JS::StringValue(JS_NewStringCopyZ(cx, "MISS"))); + if (!Headers::append_valid_header(cx, headers_val, "x-cache", miss_str_val, fun_name)) { + return false; + } + + JS::RootedValue zero_str_val(cx, JS::StringValue(JS_NewStringCopyZ(cx, "0"))); + if (!Headers::append_valid_header(cx, headers_val, "x-cache-hits", zero_str_val, fun_name)) { + return false; + } + } + + // Rest of the function handling surrogate headers remains the same + JSObject *request_headers = Request::headers(cx, request); + if (!request_headers) { + return false; + } + JS::RootedObject request_headers_val(cx, request_headers); + + auto ff_idx = Headers::lookup(cx, request_headers_val, "Fastly-FF"); + auto debug_idx = Headers::lookup(cx, request_headers_val, "Fastly-Debug"); + + if (!ff_idx.has_value() && !debug_idx.has_value()) { + JS::RootedValue delete_func(cx); + if (!JS_GetProperty(cx, headers_val, "delete", &delete_func)) { + return false; + } + { + JS::RootedValue key_val(cx, JS::StringValue(JS_NewStringCopyZ(cx, "Surrogate-Key"))); + JS::RootedValue rval(cx); + if (!JS::Call(cx, headers_val, delete_func, JS::HandleValueArray(key_val), &rval)) { + return false; + } + } + { + JS::RootedValue key_val(cx, JS::StringValue(JS_NewStringCopyZ(cx, "Surrogate-Control"))); + JS::RootedValue rval(cx); + if (!JS::Call(cx, headers_val, delete_func, JS::HandleValueArray(key_val), &rval)) { + return false; + } + } + } + + return true; +} + bool RequestOrResponse::process_pending_request(JSContext *cx, host_api::HttpPendingReq::Handle handle, JS::HandleObject context, @@ -203,24 +283,7 @@ bool RequestOrResponse::process_pending_request(JSContext *cx, return RejectPromiseWithPendingError(cx, promise); } - auto [response_handle, body] = res.unwrap(); - JS::RootedObject response_instance( - cx, JS_NewObjectWithGivenProto(cx, &Response::class_, Response::proto_obj)); - if (!response_instance) { - return false; - } - - bool is_upstream = true; - bool is_grip_upgrade = false; - RootedString backend(cx, RequestOrResponse::backend(context)); - JS::RootedObject response(cx, Response::create(cx, response_instance, response_handle, body, - is_upstream, is_grip_upgrade, backend)); - if (!response) { - return false; - } - - RequestOrResponse::set_url(response, RequestOrResponse::url(context)); - JS::RootedValue response_val(cx, JS::ObjectValue(*response)); + JS::RootedValue response_val(cx, JS::ObjectValue(*Response::create(cx, context, res.unwrap()))); return JS::ResolvePromise(cx, promise, response_val); } @@ -1303,6 +1366,19 @@ host_api::HttpPendingReq Request::pending_handle(JSObject *obj) { return res; } +std::optional Request::cache_handle(JSObject *obj) { + MOZ_ASSERT(is_instance(obj)); + + JS::Value handle_val = + JS::GetReservedSlot(obj, static_cast(Request::Slots::CacheHandle)); + + if (handle_val.isInt32()) { + return host_api::HttpCacheEntry(handle_val.toInt32()); + } + + return std::nullopt; +} + bool Request::is_downstream(JSObject *obj) { return JS::GetReservedSlot(obj, static_cast(Slots::IsDownstream)).toBoolean(); } @@ -3601,6 +3677,27 @@ bool Response::init_class(JSContext *cx, JS::HandleObject global) { (type_error_atom = JS_AtomizeAndPinString(cx, "error")); } +JSObject *Response::create(JSContext *cx, HandleObject request, host_api::Response res) { + auto [response_handle, body] = res; + JS::RootedObject response_instance( + cx, JS_NewObjectWithGivenProto(cx, &Response::class_, Response::proto_obj)); + if (!response_instance) { + return nullptr; + } + + bool is_upstream = true; + bool is_grip_upgrade = false; + RootedString backend(cx, RequestOrResponse::backend(request)); + JS::RootedObject response(cx, Response::create(cx, response_instance, response_handle, body, + is_upstream, is_grip_upgrade, backend)); + if (!response) { + return nullptr; + } + + RequestOrResponse::set_url(response, RequestOrResponse::url(request)); + return response; +} + JSObject *Response::create(JSContext *cx, JS::HandleObject response, host_api::HttpResp response_handle, host_api::HttpBody body_handle, bool is_upstream, bool is_grip, JS::HandleString backend) { diff --git a/runtime/fastly/builtins/fetch/request-response.h b/runtime/fastly/builtins/fetch/request-response.h index 0a15f02548..95e771768a 100644 --- a/runtime/fastly/builtins/fetch/request-response.h +++ b/runtime/fastly/builtins/fetch/request-response.h @@ -140,6 +140,7 @@ class Request final : public builtins::BuiltinImpl { Method = static_cast(RequestOrResponse::Slots::Count), CacheOverride, PendingRequest, + CacheHandle, ResponsePromise, IsDownstream, AutoDecompressGzip, @@ -162,6 +163,7 @@ class Request final : public builtins::BuiltinImpl { 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 std::optional cache_handle(JSObject *obj); static bool is_downstream(JSObject *obj); static const JSFunctionSpec static_methods[]; static const JSPropertySpec static_properties[]; @@ -239,6 +241,7 @@ class Response final : public builtins::BuiltinImpl { static bool init_class(JSContext *cx, JS::HandleObject global); static bool constructor(JSContext *cx, unsigned argc, JS::Value *vp); + static JSObject *create(JSContext *cx, HandleObject request, host_api::Response res); static JSObject *create(JSContext *cx, JS::HandleObject response, host_api::HttpResp response_handle, host_api::HttpBody body_handle, bool is_upstream, bool is_grip_upgrade, JS::HandleString backend); @@ -264,6 +267,9 @@ class Response final : public builtins::BuiltinImpl { static JSString *status_message(JSObject *obj); static void set_status_message_from_code(JSContext *cx, JSObject *obj, uint16_t code); + static bool add_fastly_cache_headers(JSContext *cx, JS::HandleObject self, + JS::HandleObject request, const char *fun_name); + 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); diff --git a/runtime/fastly/host-api/fastly.h b/runtime/fastly/host-api/fastly.h index 7451bc0e6a..1cce4eb54b 100644 --- a/runtime/fastly/host-api/fastly.h +++ b/runtime/fastly/host-api/fastly.h @@ -571,6 +571,10 @@ WASM_IMPORT("fastly_http_req", "send_async_streaming") int req_send_async_streaming(uint32_t req_handle, uint32_t body_handle, const char *backend, size_t backend_len, uint32_t *pending_req_out); +WASM_IMPORT("fastly_http_req", "send_async_v2") +int req_send_async_v2(uint32_t req_handle, uint32_t body_handle, const char *backend, + size_t backend_len, uint32_t streaming, uint32_t *pending_req_out); + WASM_IMPORT("fastly_http_req", "pending_req_poll") int req_pending_req_poll(uint32_t req_handle, uint32_t *is_done_out, uint32_t *resp_handle_out, uint32_t *resp_body_handle_out); diff --git a/runtime/fastly/host-api/host_api.cpp b/runtime/fastly/host-api/host_api.cpp index b430440ae8..dd16e9c8e9 100644 --- a/runtime/fastly/host-api/host_api.cpp +++ b/runtime/fastly/host-api/host_api.cpp @@ -1261,6 +1261,25 @@ Result HttpReq::send_async_streaming(HttpBody body, std::string_ return res; } +Result HttpReq::send_async_without_caching(HttpBody body, std::string_view backend, + bool streaming) { + Result res; + + fastly::fastly_host_error err; + HttpPendingReq::Handle ret; + fastly::fastly_world_string backend_str = string_view_to_world_string(backend); + if (!convert_result(fastly::req_send_async_v2(this->handle, body.handle, + reinterpret_cast(backend_str.ptr), + backend_str.len, streaming ? 1 : 0, &ret), + &err)) { + res.emplace_err(err); + } else { + res.emplace(ret); + } + + return res; +} + Result HttpReq::set_method(std::string_view method) { Result res; @@ -1852,11 +1871,20 @@ Result HttpReq::get_suggested_cache_key() const { } // HttpCacheEntry method implementations -Result HttpCacheEntry::lookup(const HttpReq &req) { +Result HttpCacheEntry::lookup(const HttpReq &req, std::span override_key) { uint32_t handle_out; - auto res = fastly::http_cache_lookup(req.handle, - 0, // No options - nullptr, &handle_out); + fastly::fastly_http_cache_lookup_options opts{}; + uint32_t opts_mask = 0; + + if (!override_key.empty()) { + MOZ_ASSERT(override_key.size() == 32); + opts.override_key = reinterpret_cast(override_key.data()); + opts.override_key_len = override_key.size(); + opts_mask |= FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_OVERRIDE_KEY; + } + + auto res = fastly::http_cache_lookup(req.handle, opts_mask, + override_key.empty() ? nullptr : &opts, &handle_out); if (res != 0) { return Result::err(host_api::APIError(res)); @@ -1865,12 +1893,21 @@ Result HttpCacheEntry::lookup(const HttpReq &req) { return Result::ok(HttpCacheEntry(handle_out)); } -Result HttpCacheEntry::transaction_lookup(const HttpReq &req) { +Result HttpCacheEntry::transaction_lookup(const HttpReq &req, + std::span override_key) { uint32_t handle_out; - auto res = fastly::http_cache_transaction_lookup(req.handle, - 0, // No options - nullptr, &handle_out); + fastly::fastly_http_cache_lookup_options opts{}; + uint32_t opts_mask = 0; + + if (!override_key.empty()) { + MOZ_ASSERT(override_key.size() == 32); + opts.override_key = reinterpret_cast(override_key.data()); + opts.override_key_len = override_key.size(); + opts_mask |= FASTLY_HTTP_CACHE_LOOKUP_OPTIONS_MASK_OVERRIDE_KEY; + } + auto res = fastly::http_cache_lookup(req.handle, opts_mask, + override_key.empty() ? nullptr : &opts, &handle_out); if (res != 0) { return Result::err(host_api::APIError(res)); } diff --git a/runtime/fastly/host-api/host_api_fastly.h b/runtime/fastly/host-api/host_api_fastly.h index 3be3ed43f1..5df345de39 100644 --- a/runtime/fastly/host-api/host_api_fastly.h +++ b/runtime/fastly/host-api/host_api_fastly.h @@ -538,6 +538,10 @@ class HttpReq final : public HttpBase { /// Send this request asynchronously, and allow sending additional data through the body. Result send_async_streaming(HttpBody body, std::string_view backend); + /// Send this request asynchronously without any caching. + Result send_async_without_caching(HttpBody body, std::string_view backend, + bool streaming = false); + /// Get the http version used for this request. /// Set the request method. @@ -638,6 +642,11 @@ class GeoIp final { static Result> lookup(std::span bytes); }; +struct HttpCacheLookupOptions { + const char *override_key_ptr; + size_t override_key_len; +}; + struct HttpCacheWriteOptions final { // Required max age of the response before considered stale uint64_t max_age_ns; @@ -693,10 +702,11 @@ class HttpCacheEntry final { bool is_valid() const { return handle != invalid; } /// Lookup a cached object without participating in request collapsing - static Result lookup(const HttpReq &req); + static Result lookup(const HttpReq &req, std::span override_key = {}); /// Lookup a cached object, participating in request collapsing - static Result transaction_lookup(const HttpReq &req); + static Result transaction_lookup(const HttpReq &req, + std::span override_key = {}); /// Insert a response into cache Result transaction_insert(const HttpResp &resp, const HttpCacheWriteOptions &opts); @@ -1175,6 +1185,7 @@ bool error_is_optional_none(APIError e); bool error_is_bad_handle(APIError e); bool error_is_unsupported(APIError e); bool error_is_buffer_len(APIError e); +bool error_is_limit_exceeded(APIError e); void handle_fastly_error(JSContext *cx, APIError err, int line, const char *func); diff --git a/runtime/fastly/host-api/host_call.cpp b/runtime/fastly/host-api/host_call.cpp index 62153b9fe6..4e9a7dbabe 100644 --- a/runtime/fastly/host-api/host_call.cpp +++ b/runtime/fastly/host-api/host_call.cpp @@ -32,6 +32,8 @@ bool error_is_unsupported(APIError e) { return e == FASTLY_HOST_ERROR_UNSUPPORTE bool error_is_buffer_len(APIError e) { return e == FASTLY_HOST_ERROR_BUFFER_LEN; } +bool error_is_limit_exceeded(APIError e) { return e == FASTLY_HOST_ERROR_LIMIT_EXCEEDED; } + void handle_kv_error(JSContext *cx, FastlyKVError err, const unsigned int err_type, int line, const char *func) { // kv error was a host call error -> report as host error diff --git a/types/cache-override.d.ts b/types/cache-override.d.ts index 23f8fdd798..e4e27d379a 100644 --- a/types/cache-override.d.ts +++ b/types/cache-override.d.ts @@ -170,6 +170,16 @@ declare module 'fastly:cache-override' { ) => void | CacheOptions | PromiseLike; }, ); + constructor(overrideInit?: { + ttl?: number; + swr?: number; + surrogateKey?: string; + pci?: boolean; + beforeSend?: (request: Request) => void | PromiseLike; + afterSend?: ( + response: Response, + ) => void | CacheOptions | PromiseLike; + }); /** * Sets the cache override mode for a request