Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 9ce44ba

Browse files
committed
Populate __STATIC_CONTENT_MANIFEST, closes #233
`__STATIC_CONTENT_MANIFEST` was previously empty to disable edge-caching. The manifest is now populated, with values having a magic prefix. URLs starting with this magic prefix are never cached. Closes #326. Closes cloudflare/workers-sdk#1632.
1 parent 34cc73a commit 9ce44ba

File tree

6 files changed

+207
-44
lines changed

6 files changed

+207
-44
lines changed

packages/cache/src/cache.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import {
1010
Awaitable,
1111
Clock,
12+
SITES_NO_CACHE_PREFIX,
1213
Storage,
1314
assertInRequest,
1415
defaultClock,
@@ -184,6 +185,11 @@ export class Cache implements CacheInterface {
184185
throw new TypeError("Cannot cache response with 'Vary: *' header.");
185186
}
186187

188+
// Disable caching of Workers Sites files, so we always serve the latest
189+
// version from disk
190+
const url = new URL(req.url);
191+
if (url.pathname.startsWith("/" + SITES_NO_CACHE_PREFIX)) return;
192+
187193
// Check if response cacheable and get expiration TTL if any
188194
const expirationTtl = getExpirationTtl(this.#clock, req, res);
189195
if (expirationTtl === undefined) return;

packages/shared/src/data.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export function randomHex(digits = 8): string {
3838
.join("");
3939
}
4040

41+
export const SITES_NO_CACHE_PREFIX = "$__MINIFLARE_SITES__$/";
42+
4143
// Arbitrary string matcher, note RegExp adheres to this interface
4244
export interface Matcher {
4345
test(string: string): boolean;

packages/sites/src/filtered.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,14 @@ import {
1212
} from "@miniflare/kv";
1313
import { Matcher, Storage } from "@miniflare/shared";
1414

15+
export interface KeyMapper {
16+
lookup(key: string): string;
17+
reverseLookup(key: string): string;
18+
}
19+
1520
export interface FilteredKVStorageNamespaceOptions {
1621
readOnly?: boolean;
22+
map?: KeyMapper;
1723
include?: Matcher;
1824
exclude?: Matcher;
1925
}
@@ -41,6 +47,7 @@ export class FilteredKVNamespace extends KVNamespace {
4147
key: string,
4248
options?: KVGetValueType | Partial<KVGetOptions>
4349
): KVValue<any> {
50+
key = this.#options.map?.lookup(key) ?? key;
4451
if (!this.#included(key)) return Promise.resolve(null);
4552
return super.get(key, options as any);
4653
}
@@ -49,6 +56,7 @@ export class FilteredKVNamespace extends KVNamespace {
4956
key: string,
5057
options?: KVGetValueType | Partial<KVGetOptions>
5158
): KVValueMeta<any, Meta> {
59+
key = this.#options.map?.lookup(key) ?? key;
5260
if (!this.#included(key)) {
5361
return Promise.resolve({ value: null, metadata: null });
5462
}
@@ -63,13 +71,15 @@ export class FilteredKVNamespace extends KVNamespace {
6371
if (this.#options.readOnly) {
6472
throw new TypeError("Unable to put into read-only namespace");
6573
}
74+
key = this.#options.map?.lookup(key) ?? key;
6675
return super.put(key, value, options);
6776
}
6877

6978
async delete(key: string): Promise<void> {
7079
if (this.#options.readOnly) {
7180
throw new TypeError("Unable to delete from read-only namespace");
7281
}
82+
key = this.#options.map?.lookup(key) ?? key;
7383
return super.delete(key);
7484
}
7585

@@ -78,7 +88,13 @@ export class FilteredKVNamespace extends KVNamespace {
7888
): Promise<KVListResult<Meta>> {
7989
const { keys, list_complete, cursor } = await super.list<Meta>(options);
8090
return {
81-
keys: keys.filter((key) => this.#included(key.name)),
91+
keys: keys.filter((key) => {
92+
if (!this.#included(key.name)) return false;
93+
if (this.#options.map !== undefined) {
94+
key.name = this.#options.map.reverseLookup(key.name);
95+
}
96+
return true;
97+
}),
8298
list_complete,
8399
cursor,
84100
};

packages/sites/src/plugin.ts

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
1+
import assert from "assert";
12
import path from "path";
23
import {
4+
Matcher,
35
Option,
46
OptionType,
57
Plugin,
68
PluginContext,
9+
SITES_NO_CACHE_PREFIX,
710
SetupResult,
811
globsToMatcher,
912
} from "@miniflare/shared";
10-
import { FilteredKVNamespace } from "./filtered";
13+
import type { FileStorage } from "@miniflare/storage-file";
14+
import { FilteredKVNamespace, KeyMapper } from "./filtered";
1115

1216
export interface SitesOptions {
1317
sitePath?: string;
1418
siteInclude?: string[];
1519
siteExclude?: string[];
1620
}
1721

22+
const SITES_KEY_MAPPER: KeyMapper = {
23+
lookup(key: string): string {
24+
return key.startsWith(SITES_NO_CACHE_PREFIX)
25+
? decodeURIComponent(key.substring(SITES_NO_CACHE_PREFIX.length))
26+
: key;
27+
},
28+
reverseLookup(key: string): string {
29+
// `encodeURIComponent()` ensures `E-Tag`s used by `@cloudflare/kv-asset-handler`
30+
// are always byte strings, as required by `undici`
31+
return SITES_NO_CACHE_PREFIX + encodeURIComponent(key);
32+
},
33+
};
34+
1835
export class SitesPlugin extends Plugin<SitesOptions> implements SitesOptions {
1936
@Option({
2037
type: OptionType.STRING,
@@ -42,48 +59,72 @@ export class SitesPlugin extends Plugin<SitesOptions> implements SitesOptions {
4259
})
4360
siteExclude?: string[];
4461

45-
readonly #setupResult: Promise<SetupResult>;
62+
readonly #include?: Matcher;
63+
readonly #exclude?: Matcher;
64+
65+
readonly #resolvedSitePath?: string;
66+
readonly #storage?: FileStorage;
67+
readonly #__STATIC_CONTENT?: FilteredKVNamespace;
4668

4769
constructor(ctx: PluginContext, options?: SitesOptions) {
4870
super(ctx);
4971
this.assignOptions(options);
72+
if (!this.sitePath) return;
5073

51-
// setup() will be called each time a site file changes, but there's no need
52-
// to recreate the namespace each time, so create it once and then return it
53-
this.#setupResult = this.#setup();
54-
}
74+
// Lots of sites stuff is constant between reloads, so initialise it once
5575

56-
async #setup(): Promise<SetupResult> {
57-
if (!this.sitePath) return {};
76+
// Create include/exclude filters
77+
this.#include = this.siteInclude && globsToMatcher(this.siteInclude);
78+
this.#exclude = this.siteExclude && globsToMatcher(this.siteExclude);
5879

5980
// Create file KV storage with sanitisation DISABLED so paths containing
6081
// /'s resolve correctly
6182
const {
6283
FileStorage,
6384
}: typeof import("@miniflare/storage-file") = require("@miniflare/storage-file");
64-
const sitePath = path.resolve(this.ctx.rootPath, this.sitePath);
65-
const storage = new FileStorage(sitePath, false);
85+
this.#resolvedSitePath = path.resolve(this.ctx.rootPath, this.sitePath);
86+
this.#storage = new FileStorage(this.#resolvedSitePath, false);
87+
88+
// Build KV namespace that strips prefix, and only returns matched keys
89+
this.#__STATIC_CONTENT = new FilteredKVNamespace(this.#storage, {
90+
readOnly: true,
91+
map: SITES_KEY_MAPPER,
92+
include: this.#include,
93+
exclude: this.#exclude,
94+
});
95+
}
96+
97+
async setup(): Promise<SetupResult> {
98+
if (!this.sitePath) return {};
99+
assert(
100+
this.#resolvedSitePath !== undefined &&
101+
this.#storage !== undefined &&
102+
this.#__STATIC_CONTENT !== undefined
103+
);
104+
105+
// Build manifest, including prefix to disable caching of sites files
106+
const staticContentManifest: Record<string, string> = {};
107+
const result = await this.#storage.list();
108+
assert.strictEqual(result.cursor, "");
109+
for (const { name } of result.keys) {
110+
if (this.#include !== undefined && !this.#include.test(name)) continue;
111+
if (this.#exclude !== undefined && this.#exclude.test(name)) continue;
112+
staticContentManifest[name] = SITES_KEY_MAPPER.reverseLookup(name);
113+
}
114+
const __STATIC_CONTENT_MANIFEST = JSON.stringify(staticContentManifest);
115+
66116
const bindings = {
67-
__STATIC_CONTENT: new FilteredKVNamespace(storage, {
68-
readOnly: true,
69-
include: this.siteInclude && globsToMatcher(this.siteInclude),
70-
exclude: this.siteExclude && globsToMatcher(this.siteExclude),
71-
}),
72-
// Empty manifest means @cloudflare/kv-asset-handler will use the request
73-
// path as the file path and won't edge cache files
74-
__STATIC_CONTENT_MANIFEST: {},
117+
__STATIC_CONTENT: this.#__STATIC_CONTENT,
118+
__STATIC_CONTENT_MANIFEST,
75119
};
76120
// Allow `import manifest from "__STATIC_CONTENT_MANIFEST"`
77121
const additionalModules = {
78-
__STATIC_CONTENT_MANIFEST: { default: "{}" },
122+
__STATIC_CONTENT_MANIFEST: { default: __STATIC_CONTENT_MANIFEST },
79123
};
80124

81125
// Whilst FileStorage will always serve the latest files, we want to
82-
// force a reload when these files change for live reload.
83-
return { bindings, watch: [sitePath], additionalModules };
84-
}
85-
86-
async setup(): Promise<SetupResult> {
87-
return this.#setupResult;
126+
// force a reload when these files change for live reload and to rebuild
127+
// the manifest.
128+
return { bindings, watch: [this.#resolvedSitePath], additionalModules };
88129
}
89130
}

0 commit comments

Comments
 (0)