emdash-multisite 0.0.0-stage → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +681 -2
- package/SETUP.md +205 -0
- package/bin/setup.mjs +83 -0
- package/package.json +91 -4
- package/src/abuse.ts +65 -0
- package/src/auth-links.ts +21 -0
- package/src/auth-ui.ts +390 -0
- package/src/backup.ts +137 -0
- package/src/bootstrap.ts +51 -0
- package/src/clone-run.ts +57 -0
- package/src/clone.ts +77 -0
- package/src/config.ts +17 -0
- package/src/console-page.ts +318 -0
- package/src/console.ts +141 -0
- package/src/db/d1.ts +9 -0
- package/src/db/sqlite.ts +6 -0
- package/src/demo.ts +23 -0
- package/src/dialect.ts +109 -0
- package/src/edge-cache.ts +249 -0
- package/src/fleet-actions.ts +44 -0
- package/src/fleet-blocks.ts +115 -0
- package/src/fleet.ts +151 -0
- package/src/handoff.ts +58 -0
- package/src/index.ts +35 -0
- package/src/log.ts +6 -0
- package/src/media-bucket.ts +44 -0
- package/src/middleware.ts +607 -0
- package/src/orgs.ts +343 -0
- package/src/passwords.ts +60 -0
- package/src/platform-env.ts +10 -0
- package/src/plugin-entry.ts +145 -0
- package/src/plugin.ts +26 -0
- package/src/provision.ts +17 -0
- package/src/purge.ts +105 -0
- package/src/registry.ts +421 -0
- package/src/rewrite.ts +199 -0
- package/src/rules.ts +521 -0
- package/src/runtime.ts +69 -0
- package/src/scheduled.ts +107 -0
- package/src/scope.ts +178 -0
- package/src/sites.ts +97 -0
- package/src/slots.ts +38 -0
- package/src/storage.ts +57 -0
- package/src/vite.ts +256 -0
- package/templates/astro.config.snippet.mjs +42 -0
- package/templates/worker.ts +61 -0
- package/templates/wrangler.snippet.jsonc +26 -0
package/src/vite.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build-time patch: make core's process-wide caches per-site.
|
|
5
|
+
*
|
|
6
|
+
* Core keeps each cache as ONE object on `globalThis`, captured when the module
|
|
7
|
+
* loads (or re-read from a `Symbol.for` slot on every use), so the wrapper
|
|
8
|
+
* cannot swap it per request. This plugin rewrites those declarations in core's
|
|
9
|
+
* published code:
|
|
10
|
+
* - "holder" caches: `const x = g[KEY] ?? (() => {...})()` -> `siteScoped(key, init)`
|
|
11
|
+
* - "slot" caches: `const store = globalThis` -> `siteGlobal([keys])`
|
|
12
|
+
*
|
|
13
|
+
* Fail-closed, in two ways:
|
|
14
|
+
* 1. a known cache whose declaration no longer matches errors the build;
|
|
15
|
+
* 2. a `Symbol.for("emdash:...")` key that is neither patched nor listed as
|
|
16
|
+
* site-independent errors the build (core added a cache we don't know).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Caches declared as `const x = g[KEY] ?? (() => {...create + store...})()`. */
|
|
20
|
+
export const HOLDER_KEYS = [
|
|
21
|
+
// emdash 1.3.x versioned/renamed these per-site holders (was site-settings,
|
|
22
|
+
// collection-slugs, taxonomy-defs). The @N suffix is core's cache-key version.
|
|
23
|
+
"emdash:site-settings:swr",
|
|
24
|
+
"emdash:collection-slugs@2",
|
|
25
|
+
"emdash:taxonomy-defs@2",
|
|
26
|
+
"emdash:taxonomy-names",
|
|
27
|
+
// emdash 1.4.0 (#4243) added stale-while-revalidate variants of the taxonomy
|
|
28
|
+
// holders. Same per-site taxonomy data; still holders.
|
|
29
|
+
"emdash:taxonomy-names:swr",
|
|
30
|
+
"emdash:taxonomy-defs:swr",
|
|
31
|
+
"emdash:byline-field-defs",
|
|
32
|
+
// emdash 1.4.0: memoized resolved menu URLs, keyed by name:locale:trailingSlash.
|
|
33
|
+
// Per-site content (each tenant's menus differ), so scope it per site.
|
|
34
|
+
"emdash:menu-resolved-namespaces",
|
|
35
|
+
"emdash:url-pattern-cache",
|
|
36
|
+
"emdash:redirect-cache",
|
|
37
|
+
"emdash:redirect-artifact-repair",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
/** Caches re-read from a `Symbol.for` slot on every use through a `globalThis` alias. */
|
|
41
|
+
export const SLOT_KEYS = [
|
|
42
|
+
"emdash:db-cache",
|
|
43
|
+
"emdash:seed-state",
|
|
44
|
+
"emdash:runtime-holder",
|
|
45
|
+
"emdash:setup-verified",
|
|
46
|
+
"@emdash-cms/core/secrets-cache@2",
|
|
47
|
+
// Per-key promise locks for media-usage refreshes; two sites sharing a collection name
|
|
48
|
+
// ("posts") would otherwise serialise behind, or inherit failures from, each other.
|
|
49
|
+
"emdash.mediaUsage.contentLocks",
|
|
50
|
+
"emdash.mediaUsage.collectionLocks",
|
|
51
|
+
// Synonyms come from the site's own KV settings.
|
|
52
|
+
"emdash.ai-search.synonym-cache",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Keys that hold no per-site data (build config, per-request state, platform
|
|
57
|
+
* health), so sharing them across sites is correct.
|
|
58
|
+
*/
|
|
59
|
+
export const SITE_INDEPENDENT_KEYS: Readonly<Record<string, string>> = {
|
|
60
|
+
"emdash:stored-config": "build-time config; one build serves every site",
|
|
61
|
+
"emdash:i18n-config": "build-time config",
|
|
62
|
+
"emdash:request-context": "AsyncLocalStorage; per request",
|
|
63
|
+
"emdash:request-cache": "WeakMap keyed by request context; per request",
|
|
64
|
+
"emdash:request-scoped-dbs": "WeakSet of handles; holds no data",
|
|
65
|
+
"emdash:deferred-tasks": "in-flight promise tracker",
|
|
66
|
+
"emdash:content-save-hook-context": "AsyncLocalStorage; per request",
|
|
67
|
+
"emdash:dev-emails": "dev console store",
|
|
68
|
+
"emdash:foldedTerms": "per-row marker, not a cache",
|
|
69
|
+
"emdash:foldedAuthorByline": "per-row marker on the data object (emdash 1.4.0 #4167), not a cache",
|
|
70
|
+
"emdash:foldedBylines": "per-row marker, not a cache",
|
|
71
|
+
"emdash:foldedBylinesExist": "per-row marker, not a cache",
|
|
72
|
+
"emdash:migration-config": "build-time integration metadata",
|
|
73
|
+
"emdash:d1-session-guard": "platform health latch",
|
|
74
|
+
"emdash:do-singleton-bookmark-sinks": "keyed per Durable Object",
|
|
75
|
+
"emdash:inline-code-block-lowlight": "library instance",
|
|
76
|
+
"emdash:admin-code-block-lowlight": "library instance",
|
|
77
|
+
"emdash:sandbox-media-storage-callback": "sandboxed plugins unsupported in v1",
|
|
78
|
+
"emdash.ai-search.workers-module": "process-wide module import",
|
|
79
|
+
"emdash.ai-search.config": "build-time plugin config; same for every site",
|
|
80
|
+
"emdash.registry.publisherHandles": "ATProto handle lookups on the public network; identical for every site",
|
|
81
|
+
"emdash.registry.authoritativeRecordReader": "test override slot",
|
|
82
|
+
"emdash.openapi.cachedSpec": "built only from build-time config (maxUploadSize); no site data",
|
|
83
|
+
"@emdash-cms/cloudflare:astro-app": "the Astro app object; one build serves every site",
|
|
84
|
+
"@emdash-cms/cloudflare:cache-provider": "platform cache provider; one per Worker",
|
|
85
|
+
"emdash:auth:microsoft-jwks": "Microsoft's public signing keys, keyed by Microsoft tenant; identical for every site",
|
|
86
|
+
"emdash:warned-deprecated-capability-plugins": "log-once set of plugin ids",
|
|
87
|
+
// ponytail: only site-independent while the opt-in object cache is OFF. With it on, the
|
|
88
|
+
// shared KV needs a per-site key prefix (not built yet), so do not enable it multi-site.
|
|
89
|
+
"emdash:object-cache:memory": "object cache (opt-in, off by default)",
|
|
90
|
+
"emdash:object-cache:backend": "object cache (opt-in, off by default)",
|
|
91
|
+
"emdash:object-cache:epochs": "object cache (opt-in, off by default)",
|
|
92
|
+
"emdash:object-cache:pending-bumps": "object cache (opt-in, off by default)",
|
|
93
|
+
"emdash:object-cache:last-content-write": "object cache (opt-in, off by default)",
|
|
94
|
+
"emdash:object-cache:pending-content-write": "object cache (opt-in, off by default)",
|
|
95
|
+
"emdash:object-cache:write-scope": "object cache (opt-in, off by default)",
|
|
96
|
+
// ponytail: sandboxed plugins are not supported multi-site in v1 (their bridge opens its own DB).
|
|
97
|
+
"emdash:sandbox-content-create-callbacks": "sandboxed plugins unsupported in v1",
|
|
98
|
+
"emdash:sandbox-taxonomy-write-callbacks": "sandboxed plugins unsupported in v1",
|
|
99
|
+
"emdash:sandbox-content-action-callbacks": "sandboxed plugins unsupported in v1",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Every Symbol.for key owned by EmDash code: "emdash:..", "emdash.x.y" and "@emdash-cms/..".
|
|
103
|
+
// (Not astro.* or other libraries' keys.)
|
|
104
|
+
const KEY_PATTERN = /Symbol\.for\("((?:emdash[:.]|@emdash-cms\/)[^"]*)"\)/g;
|
|
105
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
106
|
+
|
|
107
|
+
/** Names of the constants bound to `Symbol.for("<key>")` in this module. */
|
|
108
|
+
function keyConsts(code: string, key: string): string[] {
|
|
109
|
+
const re = new RegExp(`(?:const|let|var) (\\w+)(?::[^=\\n]+)? = Symbol\\.for\\("${escapeRe(key)}"\\)`, "g");
|
|
110
|
+
return [...code.matchAll(re)].map((m) => m[1]!);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Cache keys mentioned in a module. */
|
|
114
|
+
export function keysIn(code: string): string[] {
|
|
115
|
+
return [...new Set([...code.matchAll(KEY_PATTERN)].map((m) => m[1]!))];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const VIRTUAL = "virtual:emdash-multisite/scope";
|
|
119
|
+
const RESOLVED = `\0${VIRTUAL}`;
|
|
120
|
+
const SCOPE_FILE = fileURLToPath(new URL("./scope.ts", import.meta.url));
|
|
121
|
+
|
|
122
|
+
function patchHolder(code: string, key: string, id: string): string {
|
|
123
|
+
const consts = keyConsts(code, key);
|
|
124
|
+
// dist: const c = g[KEY] ?? (() => {...})();
|
|
125
|
+
// src : const c: Type =\n // comment\n (g[KEY] as Type | undefined) ??\n (() => {...})();
|
|
126
|
+
// src (1.4.0 globalThis-cast style):
|
|
127
|
+
// const c: Type =\n // comment\n ((globalThis as Record<symbol, unknown>)[KEY] as\n | Type\n | undefined) ??\n (() => {...})();
|
|
128
|
+
// The store accessor is either a bare alias `g[KEY]` or a parenthesized cast
|
|
129
|
+
// `(globalThis as Record<symbol, unknown>)[KEY]`, and the trailing `as <Type>`
|
|
130
|
+
// may span multiple lines, so match the accessor and cast newline-tolerantly.
|
|
131
|
+
for (const k of consts) {
|
|
132
|
+
const re = new RegExp(
|
|
133
|
+
`const (\\w+)(?::[^=\\n]+)? =\\s*(?:\\/\\/[^\\n]*\\n\\s*)*\\(*\\s*(?:\\(globalThis as [^)]+\\)|\\w+)\\[${k}\\](?:\\s+as\\s[\\s\\S]*?)?\\s*\\)*\\s*\\?\\?\\s*(\\(\\(\\) => \\{[\\s\\S]*?\\n?\\s*\\}\\)\\(\\));`,
|
|
134
|
+
);
|
|
135
|
+
if (re.test(code)) {
|
|
136
|
+
return code.replace(re, (_m, name: string, init: string) =>
|
|
137
|
+
`const ${name} = __siteScoped(${JSON.stringify(key)}, () => ${init});`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
throw new Error(
|
|
141
|
+
`[multisite] cannot patch cache "${key}" in ${id}: its declaration no longer matches the expected shape. ` +
|
|
142
|
+
`EmDash core changed; update patchHolder in emdash-multisite/src/vite.ts.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Rewrite the `const alias = globalThis` that belongs to each slot cache.
|
|
148
|
+
*
|
|
149
|
+
* An alias name like `holder` or `g` is often reused by unrelated functions in
|
|
150
|
+
* one bundled file, so the declaration is chosen by position: the closest
|
|
151
|
+
* `<alias> = globalThis` BEFORE the first `alias[KEY]` use of that key. Aliases
|
|
152
|
+
* are patched individually, each with only the keys it is used with.
|
|
153
|
+
*/
|
|
154
|
+
function patchSlots(code: string, keys: string[], id: string): string {
|
|
155
|
+
// alias declaration offset -> keys read through it
|
|
156
|
+
const byDecl = new Map<number, { alias: string; keys: Set<string> }>();
|
|
157
|
+
for (const key of keys) {
|
|
158
|
+
const consts = keyConsts(code, key);
|
|
159
|
+
if (consts.length === 0) {
|
|
160
|
+
throw new Error(`[multisite] cannot patch cache "${key}" in ${id}: no Symbol.for constant found.`);
|
|
161
|
+
}
|
|
162
|
+
let used = false;
|
|
163
|
+
for (const k of consts) {
|
|
164
|
+
for (const m of code.matchAll(new RegExp(`(\\w+)\\[${k}\\]`, "g"))) {
|
|
165
|
+
used = true;
|
|
166
|
+
const alias = m[1]!;
|
|
167
|
+
const declRe = new RegExp(`(?:const|let) ${alias}(?::[^=\\n]+)? = globalThis(?: as [^;\\n]+)?;`, "g");
|
|
168
|
+
let best: number | undefined;
|
|
169
|
+
for (const d of code.matchAll(declRe)) {
|
|
170
|
+
if (d.index! < m.index!) best = d.index!;
|
|
171
|
+
}
|
|
172
|
+
if (best === undefined) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
`[multisite] cannot patch cache "${key}" in ${id}: no \`${alias} = globalThis\` precedes its use.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const entry = byDecl.get(best) ?? { alias, keys: new Set<string>() };
|
|
178
|
+
entry.keys.add(key);
|
|
179
|
+
byDecl.set(best, entry);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!used) throw new Error(`[multisite] cannot patch cache "${key}" in ${id}: no slot access found.`);
|
|
183
|
+
}
|
|
184
|
+
// Apply from the end so earlier offsets stay valid.
|
|
185
|
+
let out = code;
|
|
186
|
+
for (const [offset, { alias, keys: ks }] of [...byDecl].sort((a, b) => b[0] - a[0])) {
|
|
187
|
+
const declRe = new RegExp(`((?:const|let) ${alias}(?::[^=\\n]+)? = )globalThis(?: as [^;\\n]+)?;`, "y");
|
|
188
|
+
declRe.lastIndex = offset;
|
|
189
|
+
const m = declRe.exec(out);
|
|
190
|
+
if (!m) throw new Error(`[multisite] internal: lost declaration of "${alias}" in ${id}.`);
|
|
191
|
+
const replacement = `${m[1]}__siteGlobal(${JSON.stringify([...ks])});`;
|
|
192
|
+
out = out.slice(0, offset) + replacement + out.slice(offset + m[0].length);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Rewrite one module. Returns null when it holds no cache keys. Throws when a
|
|
199
|
+
* known cache cannot be patched or an unknown cache key appears.
|
|
200
|
+
*/
|
|
201
|
+
export function patchCaches(code: string, id: string): string | null {
|
|
202
|
+
const found = keysIn(code);
|
|
203
|
+
if (found.length === 0) return null;
|
|
204
|
+
|
|
205
|
+
const unknown = found.filter(
|
|
206
|
+
(k) =>
|
|
207
|
+
!(HOLDER_KEYS as readonly string[]).includes(k) &&
|
|
208
|
+
!(SLOT_KEYS as readonly string[]).includes(k) &&
|
|
209
|
+
!(k in SITE_INDEPENDENT_KEYS),
|
|
210
|
+
);
|
|
211
|
+
if (unknown.length > 0) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`[multisite] unknown core cache key(s) ${unknown.map((k) => `"${k}"`).join(", ")} in ${id}. ` +
|
|
214
|
+
`Decide whether each holds per-site data, then add it to HOLDER_KEYS, SLOT_KEYS or SITE_INDEPENDENT_KEYS ` +
|
|
215
|
+
`in emdash-multisite/src/vite.ts.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let out = code;
|
|
220
|
+
const holders = found.filter((k) => (HOLDER_KEYS as readonly string[]).includes(k));
|
|
221
|
+
const slots = found.filter((k) => (SLOT_KEYS as readonly string[]).includes(k));
|
|
222
|
+
for (const key of holders) out = patchHolder(out, key, id);
|
|
223
|
+
if (slots.length > 0) out = patchSlots(out, slots, id);
|
|
224
|
+
if (holders.length === 0 && slots.length === 0) return null;
|
|
225
|
+
|
|
226
|
+
const imports = [
|
|
227
|
+
holders.length > 0 ? "siteScoped as __siteScoped" : "",
|
|
228
|
+
slots.length > 0 ? "siteGlobal as __siteGlobal" : "",
|
|
229
|
+
].filter(Boolean);
|
|
230
|
+
return `import { ${imports.join(", ")} } from ${JSON.stringify(VIRTUAL)};\n${out}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Structural type so this package needs no dependency on `vite`.
|
|
234
|
+
interface VitePlugin {
|
|
235
|
+
name: string;
|
|
236
|
+
enforce: "pre";
|
|
237
|
+
resolveId(id: string): string | undefined;
|
|
238
|
+
load(id: string): string | undefined;
|
|
239
|
+
transform(code: string, id: string): { code: string; map: null } | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function multisiteCaches(): VitePlugin {
|
|
243
|
+
return {
|
|
244
|
+
name: "emdash-multisite:scope-caches",
|
|
245
|
+
enforce: "pre",
|
|
246
|
+
resolveId: (id) => (id === VIRTUAL ? RESOLVED : undefined),
|
|
247
|
+
load: (id) =>
|
|
248
|
+
id === RESOLVED ? `export { siteScoped, siteGlobal } from ${JSON.stringify(SCOPE_FILE)};` : undefined,
|
|
249
|
+
transform(code, id) {
|
|
250
|
+
const patched = patchCaches(code, id);
|
|
251
|
+
if (patched === null) return null;
|
|
252
|
+
console.log(`[multisite] per-site caches patched in ${id.split("/node_modules/").pop()}`);
|
|
253
|
+
return { code: patched, map: null };
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Merge these into your existing astro.config.mjs. Three wires: the database
|
|
2
|
+
// wrapper, the per-site cache Vite plugin, and the outer middleware (+ the
|
|
3
|
+
// provisioning/console plugin). See SETUP.md and README.md for the full config
|
|
4
|
+
// reference and what each mechanism does.
|
|
5
|
+
|
|
6
|
+
import { d1, r2 } from "@emdash-cms/cloudflare";
|
|
7
|
+
import { multisitePlugin, withSites } from "emdash-multisite";
|
|
8
|
+
import { multisiteCaches } from "emdash-multisite/vite";
|
|
9
|
+
import emdash from "emdash/astro";
|
|
10
|
+
import { defineConfig } from "astro/config";
|
|
11
|
+
|
|
12
|
+
export default defineConfig({
|
|
13
|
+
// WIRE 2 — per-site caches. Rewrites EmDash's process-wide caches to be
|
|
14
|
+
// per-site at build time. The build fails loudly if a core cache is
|
|
15
|
+
// unclassified; that is the safety net against cross-tenant leaks.
|
|
16
|
+
vite: {
|
|
17
|
+
plugins: [multisiteCaches() /* , ...your other vite plugins */],
|
|
18
|
+
},
|
|
19
|
+
integrations: [
|
|
20
|
+
emdash({
|
|
21
|
+
// WIRE 1 — database wrapper. Each site reads/writes its own prefixed
|
|
22
|
+
// tables (ec_posts$acme). The "primary" site keeps unprefixed names.
|
|
23
|
+
database: withSites(d1({ binding: "DB" }), {
|
|
24
|
+
primary: "main",
|
|
25
|
+
wildcard: "example.com", // slug.example.com -> site "slug" (production)
|
|
26
|
+
// hosts: { "acme.example.com": "acme" }, // test / workers.dev (no wildcard)
|
|
27
|
+
// themes, defaultTheme, limits, appUrl, accountTab, authLinks — see README.
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
// Per-site media in one R2 bucket (keys namespaced per site).
|
|
31
|
+
storage: { ...r2({ binding: "MEDIA" }), entrypoint: "emdash-multisite/storage" },
|
|
32
|
+
|
|
33
|
+
// WIRE 3 — outer middleware: hostname -> site, and (on primary)
|
|
34
|
+
// "create a Better Auth organization" -> "provision a site".
|
|
35
|
+
middleware: { outer: "emdash-multisite/middleware" },
|
|
36
|
+
|
|
37
|
+
// Provisioning / console / fleet glue (org-create guard, status API,
|
|
38
|
+
// theme switch, customer "My sites" console).
|
|
39
|
+
plugins: [multisitePlugin() /* , ...your other emdash plugins */],
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/worker.ts — host-aware edge cache for a multi-tenant EmDash app.
|
|
2
|
+
//
|
|
3
|
+
// Copied in by `npx create-emdash-multisite` (or by hand from SETUP.md). This is
|
|
4
|
+
// the one piece that cannot live inside the npm package: it is per-project
|
|
5
|
+
// Cloudflare Worker config.
|
|
6
|
+
//
|
|
7
|
+
// Why it exists: Cloudflare Workers Cache keys entries by path + query, NOT by
|
|
8
|
+
// hostname, and one Worker serves the apex and every tenant subdomain. So the
|
|
9
|
+
// host is put into the cache key via `ctx.props` of a cached inner entrypoint.
|
|
10
|
+
// Two entrypoints (declared in wrangler.jsonc `exports`):
|
|
11
|
+
// default gateway, caching OFF. Runs every request, decides cacheable or not.
|
|
12
|
+
// PublicCache caching ON. Runs the normal handler; the response guard is
|
|
13
|
+
// default-deny (only a clean anonymous 200 of an allowed type is stored).
|
|
14
|
+
import handler, { createScheduledHandler } from "@emdash-cms/cloudflare/worker";
|
|
15
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
16
|
+
import {
|
|
17
|
+
EDGE_REQUEST_HEADER,
|
|
18
|
+
cacheableRequest,
|
|
19
|
+
guardResponse,
|
|
20
|
+
normalizeHost,
|
|
21
|
+
} from "emdash-multisite/edge-cache";
|
|
22
|
+
import { perSiteScheduled } from "emdash-multisite/scheduled";
|
|
23
|
+
|
|
24
|
+
/** Inner entrypoint: Workers Cache is enabled for THIS entrypoint only. `props.host` is part of the key. */
|
|
25
|
+
export class PublicCache extends WorkerEntrypoint<Env, { host: string; gpc: boolean }> {
|
|
26
|
+
async fetch(request: Request): Promise<Response> {
|
|
27
|
+
// Ask the multisite middleware to label the response with its site (used for the purge tag).
|
|
28
|
+
const headers = new Headers(request.headers);
|
|
29
|
+
headers.set(EDGE_REQUEST_HEADER, "1");
|
|
30
|
+
const res = await handler.fetch(
|
|
31
|
+
new Request(request, { headers }) as never,
|
|
32
|
+
this.env as never,
|
|
33
|
+
this.ctx as never,
|
|
34
|
+
);
|
|
35
|
+
return guardResponse(res as Response);
|
|
36
|
+
}
|
|
37
|
+
/** Purges only work from inside the cached entrypoint (from the gateway they report success and do nothing), so callers RPC here. */
|
|
38
|
+
async purgeTag(tags: string[]) {
|
|
39
|
+
const { cache } = await import("cloudflare:workers");
|
|
40
|
+
return cache.purge({ tags });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Gateway = {
|
|
45
|
+
exports: Record<string, (o: { props: { host: string; gpc: boolean } }) => Fetcher>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default {
|
|
49
|
+
...handler,
|
|
50
|
+
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
// Not cacheable (signed-in, non-GET, has query, admin/api): straight through, uncached.
|
|
53
|
+
if (!cacheableRequest(request.method, url, request.headers)) {
|
|
54
|
+
return handler.fetch(request as never, env as never, ctx as never);
|
|
55
|
+
}
|
|
56
|
+
const props = { host: normalizeHost(url.hostname), gpc: request.headers.get("sec-gpc") === "1" };
|
|
57
|
+
return (ctx as unknown as Gateway).exports.PublicCache({ props }).fetch(request);
|
|
58
|
+
},
|
|
59
|
+
// EmDash's scheduled handler serves one site; run it once per ready site.
|
|
60
|
+
scheduled: perSiteScheduled(createScheduledHandler() as never) as ExportedHandler["scheduled"],
|
|
61
|
+
} satisfies ExportedHandler;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Merge these keys into your existing wrangler.jsonc.
|
|
2
|
+
// They point the Worker at src/worker.ts and declare the two cache entrypoints
|
|
3
|
+
// the host-aware edge cache needs (see templates/worker.ts and SETUP.md).
|
|
4
|
+
{
|
|
5
|
+
"main": "./src/worker.ts",
|
|
6
|
+
|
|
7
|
+
// Host-aware edge cache. The default (gateway) entrypoint is uncached; the
|
|
8
|
+
// PublicCache entrypoint is cached, with the request host in ctx.props so the
|
|
9
|
+
// cache key is per-site. Do NOT enable Workers Cache any other way.
|
|
10
|
+
"cache": { "enabled": true },
|
|
11
|
+
"exports": {
|
|
12
|
+
"default": { "type": "worker", "cache": { "enabled": false } },
|
|
13
|
+
"PublicCache": { "type": "worker", "cache": { "enabled": true } }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// You still need your existing bindings alongside these:
|
|
17
|
+
// "d1_databases": [{ "binding": "DB", "database_name": "...", "database_id": "..." }],
|
|
18
|
+
// "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "..." }],
|
|
19
|
+
// plus routes / custom_domain, compatibility_date, etc.
|
|
20
|
+
//
|
|
21
|
+
// Production subdomains also need, on the Cloudflare side:
|
|
22
|
+
// - a custom-domain route for the apex and a route for *.example.com
|
|
23
|
+
// - a wildcard DNS record ("*" -> the Worker, proxied)
|
|
24
|
+
// On workers.dev (no wildcard) map each tenant host in SitesConfig.hosts and
|
|
25
|
+
// deploy with: wrangler versions upload --preview-alias <name>
|
|
26
|
+
}
|