|
| 1 | +import assert from "assert"; |
1 | 2 | import path from "path"; |
2 | 3 | import { |
| 4 | + Matcher, |
3 | 5 | Option, |
4 | 6 | OptionType, |
5 | 7 | Plugin, |
6 | 8 | PluginContext, |
| 9 | + SITES_NO_CACHE_PREFIX, |
7 | 10 | SetupResult, |
8 | 11 | globsToMatcher, |
9 | 12 | } from "@miniflare/shared"; |
10 | | -import { FilteredKVNamespace } from "./filtered"; |
| 13 | +import type { FileStorage } from "@miniflare/storage-file"; |
| 14 | +import { FilteredKVNamespace, KeyMapper } from "./filtered"; |
11 | 15 |
|
12 | 16 | export interface SitesOptions { |
13 | 17 | sitePath?: string; |
14 | 18 | siteInclude?: string[]; |
15 | 19 | siteExclude?: string[]; |
16 | 20 | } |
17 | 21 |
|
| 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 | + |
18 | 35 | export class SitesPlugin extends Plugin<SitesOptions> implements SitesOptions { |
19 | 36 | @Option({ |
20 | 37 | type: OptionType.STRING, |
@@ -42,48 +59,72 @@ export class SitesPlugin extends Plugin<SitesOptions> implements SitesOptions { |
42 | 59 | }) |
43 | 60 | siteExclude?: string[]; |
44 | 61 |
|
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; |
46 | 68 |
|
47 | 69 | constructor(ctx: PluginContext, options?: SitesOptions) { |
48 | 70 | super(ctx); |
49 | 71 | this.assignOptions(options); |
| 72 | + if (!this.sitePath) return; |
50 | 73 |
|
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 |
55 | 75 |
|
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); |
58 | 79 |
|
59 | 80 | // Create file KV storage with sanitisation DISABLED so paths containing |
60 | 81 | // /'s resolve correctly |
61 | 82 | const { |
62 | 83 | FileStorage, |
63 | 84 | }: 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 | + |
66 | 116 | 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, |
75 | 119 | }; |
76 | 120 | // Allow `import manifest from "__STATIC_CONTENT_MANIFEST"` |
77 | 121 | const additionalModules = { |
78 | | - __STATIC_CONTENT_MANIFEST: { default: "{}" }, |
| 122 | + __STATIC_CONTENT_MANIFEST: { default: __STATIC_CONTENT_MANIFEST }, |
79 | 123 | }; |
80 | 124 |
|
81 | 125 | // 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 }; |
88 | 129 | } |
89 | 130 | } |
0 commit comments