Skip to content

Commit

Permalink
Assorted Cache API fixes (#433)
Browse files Browse the repository at this point in the history
* Enable cache persistence

* Disable caching of Workers Sites assets

* Respect `cache: false` option to disable caching

* Respect `cacheWarnUsage: true` option for `workers.dev` subdomains

* Move `cache` and `cacheWarnUsage` back to per-worker options
  • Loading branch information
mrbbot committed Nov 1, 2023
1 parent 9169ec9 commit 246570a
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 42 deletions.
14 changes: 4 additions & 10 deletions packages/miniflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,21 +291,15 @@ parameter in module format Workers.

#### Cache

<!--TODO: implement these options-->

- `cache?: boolean`

_Not yet supported_, the Cache API is always enabled.

<!--If `true`, default and named caches will be disabled. The Cache API will still
be available, it just won't cache anything.-->
If `false`, default and named caches will be disabled. The Cache API will
still be available, it just won't cache anything.

- `cacheWarnUsage?: boolean`

_Not yet supported_

<!--If `true`, the first use of the Cache API will log a warning stating that the
Cache API is unsupported on `workers.dev` subdomains.-->
If `true`, the first use of the Cache API will log a warning stating that the
Cache API is unsupported on `workers.dev` subdomains.

#### Durable Objects

Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/plugins/cache/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const HEADER_CACHE_WARN_USAGE = "MF-Cache-Warn";
9 changes: 8 additions & 1 deletion packages/miniflare/src/plugins/cache/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import CachePolicy from "http-cache-semantics";
import { Headers, HeadersInit, Request, Response, fetch } from "undici";
import { Clock, Log, millisToSeconds } from "../../shared";
import { Storage } from "../../storage";
import { isSitesRequest } from "../kv";
import { CacheMiss, PurgeFailure, StorageFailure } from "./errors";
import { _getRangeResponse } from "./range";

Expand Down Expand Up @@ -210,8 +211,11 @@ export class CacheGateway {
) {}

async match(request: Request): Promise<Response> {
// Never cache Workers Sites requests, so we always return on-disk files
if (isSitesRequest(request)) throw new CacheMiss();

const cached = await this.storage.get<CacheMetadata>(request.url);
if (!cached || !cached?.metadata) throw new CacheMiss();
if (cached?.metadata === undefined) throw new CacheMiss();

const response = new CacheResponse(
cached.metadata,
Expand All @@ -228,6 +232,9 @@ export class CacheGateway {
}

async put(request: Request, value: ArrayBuffer): Promise<Response> {
// Never cache Workers Sites requests, so we always return on-disk files
if (isSitesRequest(request)) return new Response(null, { status: 204 });

const response = await HttpParser.get().parse(new Uint8Array(value));

const { storable, expiration, headers } = getExpiration(
Expand Down
49 changes: 41 additions & 8 deletions packages/miniflare/src/plugins/cache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import {
BINDING_SERVICE_LOOPBACK,
BINDING_TEXT_PERSIST,
BINDING_TEXT_PLUGIN,
CfHeader,
HEADER_PERSIST,
PersistenceSchema,
Plugin,
encodePersist,
} from "../shared";
import { HEADER_CACHE_WARN_USAGE } from "./constants";
import { CacheGateway } from "./gateway";
import { CacheRouter } from "./router";

Expand All @@ -19,17 +22,39 @@ export const CacheOptionsSchema = z.object({
export const CacheSharedOptionsSchema = z.object({
cachePersist: PersistenceSchema,
});

const BINDING_JSON_CACHE_WARN_USAGE = "MINIFLARE_CACHE_WARN_USAGE";

export const CACHE_LOOPBACK_SCRIPT = `addEventListener("fetch", (event) => {
let request = event.request;
const request = new Request(event.request);
const url = new URL(request.url);
url.pathname = \`/\${${BINDING_TEXT_PLUGIN}}/\${encodeURIComponent(request.url)}\`;
if (globalThis.${BINDING_TEXT_PERSIST} !== undefined) {
request = new Request(request);
request.headers.set("${HEADER_PERSIST}", ${BINDING_TEXT_PERSIST});
}
if (globalThis.${BINDING_TEXT_PERSIST} !== undefined) request.headers.set("${HEADER_PERSIST}", ${BINDING_TEXT_PERSIST});
if (globalThis.${BINDING_JSON_CACHE_WARN_USAGE}) request.headers.set("${HEADER_CACHE_WARN_USAGE}", "true");
event.respondWith(${BINDING_SERVICE_LOOPBACK}.fetch(url, request));
});`;
// Cache service script that doesn't do any caching
export const NOOP_CACHE_SCRIPT = `addEventListener("fetch", (event) => {
const request = event.request;
if (request.method === "GET") {
event.respondWith(new Response(null, { status: 504, headers: { [${JSON.stringify(
CfHeader.CacheStatus
)}]: "MISS" } }));
} else if (request.method === "PUT") {
// Must consume request body, otherwise get "disconnected: read end of pipe was aborted" error from workerd
event.respondWith(request.arrayBuffer().then(() => new Response(null, { status: 204 })));
} else if (request.method === "PURGE") {
event.respondWith(new Response(null, { status: 404 }));
} else {
event.respondWith(new Response(null, { status: 405 }));
}
});`;
export const CACHE_PLUGIN_NAME = "cache";

export function getCacheServiceName(workerIndex: number) {
return `${CACHE_PLUGIN_NAME}:${workerIndex}`;
}

export const CACHE_PLUGIN: Plugin<
typeof CacheOptionsSchema,
typeof CacheSharedOptionsSchema,
Expand All @@ -42,18 +67,26 @@ export const CACHE_PLUGIN: Plugin<
getBindings() {
return [];
},
getServices() {
getServices({ sharedOptions, options, workerIndex }) {
const persistBinding = encodePersist(sharedOptions.cachePersist);
const loopbackBinding: Worker_Binding = {
name: BINDING_SERVICE_LOOPBACK,
service: { name: SERVICE_LOOPBACK },
};
return [
{
name: "cache",
name: getCacheServiceName(workerIndex),
worker: {
serviceWorkerScript: CACHE_LOOPBACK_SCRIPT,
serviceWorkerScript:
// If options.cache is undefined, default to enabling cache
options.cache === false ? NOOP_CACHE_SCRIPT : CACHE_LOOPBACK_SCRIPT,
bindings: [
...persistBinding,
{ name: BINDING_TEXT_PLUGIN, text: CACHE_PLUGIN_NAME },
{
name: BINDING_JSON_CACHE_WARN_USAGE,
json: JSON.stringify(options.cacheWarnUsage ?? false),
},
loopbackBinding,
],
compatibilityDate: "2022-09-01",
Expand Down
45 changes: 28 additions & 17 deletions packages/miniflare/src/plugins/cache/router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Request, RequestInit } from "undici";
import { Headers, Request, RequestInit } from "undici";
import {
CfHeader,
GET,
Expand All @@ -8,50 +8,61 @@ import {
Router,
decodePersist,
} from "../shared";
import { HEADER_CACHE_WARN_USAGE } from "./constants";
import { fallible } from "./errors";
import { CacheGateway } from "./gateway";

export interface CacheParams {
namespace: string;
uri: string;
}

function decodeNamespace(headers: Headers) {
const namespace = headers.get(CfHeader.CacheNamespace);
// Namespace separator `:` will become a new directory when using file-system
// backed persistent storage
return namespace === null ? `default` : `named:${namespace}`;
}

export class CacheRouter extends Router<CacheGateway> {
#warnedUsage = false;
#maybeWarnUsage(headers: Headers) {
if (!this.#warnedUsage && headers.get(HEADER_CACHE_WARN_USAGE) === "true") {
this.#warnedUsage = true;
this.log.warn(
"Cache operations will have no impact if you deploy to a workers.dev subdomain!"
);
}
}

@GET("/:uri")
match: RouteHandler<CacheParams> = async (req, params) => {
this.#maybeWarnUsage(req.headers);
const uri = decodeURIComponent(params.uri);
const namespace = decodeNamespace(req.headers);
const persist = decodePersist(req.headers);
const ns = req.headers.get(CfHeader.CacheNamespace);
const gateway = this.gatewayFactory.get(
params.namespace + ns ? `:ns:${ns}` : `:default`,
persist
);
const gateway = this.gatewayFactory.get(namespace, persist);
return fallible(gateway.match(new Request(uri, req as RequestInit)));
};

@PUT("/:uri")
put: RouteHandler<CacheParams> = async (req, params) => {
this.#maybeWarnUsage(req.headers);
const uri = decodeURIComponent(params.uri);
const namespace = decodeNamespace(req.headers);
const persist = decodePersist(req.headers);
const ns = req.headers.get(CfHeader.CacheNamespace);
const gateway = this.gatewayFactory.get(
params.namespace + ns ? `:ns:${ns}` : `:default`,
persist
);
const gateway = this.gatewayFactory.get(namespace, persist);
return fallible(
gateway.put(new Request(uri, req as RequestInit), await req.arrayBuffer())
);
};

@PURGE("/:uri")
delete: RouteHandler<CacheParams> = async (req, params) => {
this.#maybeWarnUsage(req.headers);
const uri = decodeURIComponent(params.uri);
const namespace = decodeNamespace(req.headers);
const persist = decodePersist(req.headers);
const ns = req.headers.get(CfHeader.CacheNamespace);
const gateway = this.gatewayFactory.get(
params.namespace + ns ? `:ns:${ns}` : `:default`,
persist
);
const gateway = this.gatewayFactory.get(namespace, persist);
return fallible(gateway.delete(new Request(uri, req as RequestInit)));
};
}
3 changes: 2 additions & 1 deletion packages/miniflare/src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MiniflareCoreError,
zAwaitable,
} from "../../shared";
import { getCacheServiceName } from "../cache";
import {
BINDING_SERVICE_LOOPBACK,
CloudflareFetchSchema,
Expand Down Expand Up @@ -358,7 +359,7 @@ export const CORE_PLUGIN: Plugin<
uniqueKey: className,
})),
durableObjectStorage: { inMemory: kVoid },
cacheApiOutbound: { name: "cache" },
cacheApiOutbound: { name: getCacheServiceName(workerIndex) },
},
});
serviceEntryBindings.push({
Expand Down
2 changes: 1 addition & 1 deletion packages/miniflare/src/plugins/kv/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,5 @@ export const KV_PLUGIN: Plugin<
};

export * from "./gateway";
export { maybeGetSitesManifestModule } from "./sites";
export { maybeGetSitesManifestModule, isSitesRequest } from "./sites";
export { KV_PLUGIN_NAME };
12 changes: 8 additions & 4 deletions packages/miniflare/src/plugins/kv/sites.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from "assert";
import { pathToFileURL } from "url";
import { Request } from "undici";
import { Service, Worker_Binding, Worker_Module } from "../../runtime";
import {
MatcherRegExps,
Expand Down Expand Up @@ -42,13 +43,11 @@ function testSiteRegExps(regExps: SiteMatcherRegExps, key: string): boolean {
// Magic prefix: if a URLs pathname starts with this, it shouldn't be cached.
// This ensures edge caching of Workers Sites files is disabled, and the latest
// local version is always served.
export const SITES_NO_CACHE_PREFIX = "$__MINIFLARE_SITES__$/";
const SITES_NO_CACHE_PREFIX = "$__MINIFLARE_SITES__$/";

function encodeSitesKey(key: string): string {
// `encodeURIComponent()` ensures `ETag`s used by `@cloudflare/kv-asset-handler`
// are always byte strings.
// TODO: this was required by `undici`, but depending on our new cache
// implementation, we may not need it anymore. Once we've implemented cache,
// try remove this extra URI encoding step.
return SITES_NO_CACHE_PREFIX + encodeURIComponent(key);
}
function decodeSitesKey(key: string): string {
Expand All @@ -57,6 +56,11 @@ function decodeSitesKey(key: string): string {
: key;
}

export function isSitesRequest(request: Request) {
const url = new URL(request.url);
return url.pathname.startsWith(`/${SITES_NO_CACHE_PREFIX}`);
}

const SERVICE_NAMESPACE_SITE = `${KV_PLUGIN_NAME}:site`;

const BINDING_KV_NAMESPACE_SITE = "__STATIC_CONTENT";
Expand Down

0 comments on commit 246570a

Please sign in to comment.