@decocms/tanstack 7.67.1 → 7.69.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/package.json +4 -4
- package/src/sdk/kvHydration.test.ts +9 -0
- package/src/sdk/kvHydration.ts +10 -1
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/workerEntry.ts +17 -2
- package/src/vite/plugin.js +38 -13
- package/src/vite/plugin.test.ts +65 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.69.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"lint:unused": "knip"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@decocms/blocks": "7.
|
|
31
|
-
"@decocms/blocks-admin": "7.
|
|
32
|
-
"@decocms/blocks-cli": "7.
|
|
30
|
+
"@decocms/blocks": "7.69.0",
|
|
31
|
+
"@decocms/blocks-admin": "7.69.0",
|
|
32
|
+
"@decocms/blocks-cli": "7.69.0",
|
|
33
33
|
"@deco-cx/warp-node": "^0.3.16",
|
|
34
34
|
"fast-json-patch": "^3.1.0",
|
|
35
35
|
"ws": "^8.18.0"
|
|
@@ -250,6 +250,15 @@ describe("ensureBlocksHydrated when the bundle ships no decofile", () => {
|
|
|
250
250
|
await expect(ensureBlocksHydrated(env)).rejects.toThrow(/not found and this bundle ships no/);
|
|
251
251
|
});
|
|
252
252
|
|
|
253
|
+
it("throws when fast-deploy is off at runtime instead of serving {}", async () => {
|
|
254
|
+
withStubbedBundle();
|
|
255
|
+
setBlocks({});
|
|
256
|
+
const { kv } = makeKV();
|
|
257
|
+
|
|
258
|
+
await expect(ensureBlocksHydrated({})).rejects.toThrow(/fast-deploy is off/);
|
|
259
|
+
await expect(ensureBlocksHydrated({ DECO_KV: kv })).rejects.toThrow(/fast-deploy is off/);
|
|
260
|
+
});
|
|
261
|
+
|
|
253
262
|
it("throws when the snapshot exists but is empty", async () => {
|
|
254
263
|
// A seed that wrote `{}` would otherwise take the success branch, latch,
|
|
255
264
|
// and serve the empty edge-cached site this guard exists to prevent.
|
package/src/sdk/kvHydration.ts
CHANGED
|
@@ -138,7 +138,16 @@ function bundleHasNoDecofile(): boolean {
|
|
|
138
138
|
* request retries instead of pinning the isolate to an empty decofile.
|
|
139
139
|
*/
|
|
140
140
|
export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Promise<void> {
|
|
141
|
-
if (!isFastDeployEnabled(env))
|
|
141
|
+
if (!isFastDeployEnabled(env)) {
|
|
142
|
+
// A stubbed bundle with fast-deploy off would serve an empty 200 the edge caches.
|
|
143
|
+
return bundleHasNoDecofile()
|
|
144
|
+
? Promise.reject(
|
|
145
|
+
new Error(
|
|
146
|
+
`[CMS/KV] this bundle ships no decofile but fast-deploy is off (set ${FAST_DEPLOY_ENV}=1 and bind DECO_KV)`,
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
: Promise.resolve();
|
|
150
|
+
}
|
|
142
151
|
if (G.__deco!.kvHydrated) return Promise.resolve();
|
|
143
152
|
if (G.__deco!.kvHydration) return G.__deco!.kvHydration;
|
|
144
153
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
loadRedirects,
|
|
3
|
+
matchExactRedirect,
|
|
4
|
+
matchPatternRedirect,
|
|
5
|
+
} from "@decocms/blocks/sdk/redirects";
|
|
6
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { clearRedirectCache, lookupExactRedirect } from "./kvRedirects";
|
|
8
|
+
|
|
9
|
+
/** Minimal KV binding that records every read. */
|
|
10
|
+
function makeKV(values: Record<string, string> = {}) {
|
|
11
|
+
const get = vi.fn((key: string) => Promise.resolve(values[key] ?? null));
|
|
12
|
+
return { get, put: vi.fn(), delete: vi.fn() };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ID = "sha-abc";
|
|
16
|
+
const enabled = (kv: unknown) => ({
|
|
17
|
+
DECO_FAST_DEPLOY: "1",
|
|
18
|
+
DECO_KV: kv,
|
|
19
|
+
DECO_DEPLOYMENT_ID: ID,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
clearRedirectCache();
|
|
24
|
+
vi.useRealTimers();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("lookupExactRedirect", () => {
|
|
28
|
+
it("reads redirect:<id>:<path> and returns the rule", async () => {
|
|
29
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":301}' });
|
|
30
|
+
await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toEqual({
|
|
31
|
+
from: "/old",
|
|
32
|
+
to: "/new",
|
|
33
|
+
status: 301,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns null for a missing key", async () => {
|
|
38
|
+
await expect(lookupExactRedirect(enabled(makeKV()), "/nope")).resolves.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("caches a HIT so the same path costs one KV read", async () => {
|
|
42
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":302}' });
|
|
43
|
+
await lookupExactRedirect(enabled(kv), "/old");
|
|
44
|
+
await lookupExactRedirect(enabled(kv), "/old");
|
|
45
|
+
expect(kv.get).toHaveBeenCalledTimes(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("caches a MISS too — that is the common case on every request", async () => {
|
|
49
|
+
const kv = makeKV();
|
|
50
|
+
await lookupExactRedirect(enabled(kv), "/nope");
|
|
51
|
+
await lookupExactRedirect(enabled(kv), "/nope");
|
|
52
|
+
expect(kv.get).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("re-reads after the TTL, which is how a redirect edit propagates", async () => {
|
|
56
|
+
// The rules live outside the decofile, so a redirect-only sync leaves the
|
|
57
|
+
// revision identical and the poller has nothing to react to. The TTL is
|
|
58
|
+
// the only propagation path.
|
|
59
|
+
const kv = makeKV();
|
|
60
|
+
await lookupExactRedirect(enabled(kv), "/soon");
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
vi.setSystemTime(Date.now() + 61_000);
|
|
63
|
+
await lookupExactRedirect(enabled(kv), "/soon");
|
|
64
|
+
expect(kv.get).toHaveBeenCalledTimes(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is inert when fast-deploy is off", async () => {
|
|
68
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new","status":301}' });
|
|
69
|
+
const env = { DECO_KV: kv, DECO_DEPLOYMENT_ID: ID }; // no DECO_FAST_DEPLOY
|
|
70
|
+
await expect(lookupExactRedirect(env, "/old")).resolves.toBeNull();
|
|
71
|
+
expect(kv.get).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("never reads another deployment's keys when the id is unresolvable", async () => {
|
|
75
|
+
const kv = makeKV();
|
|
76
|
+
await lookupExactRedirect({ DECO_FAST_DEPLOY: "1", DECO_KV: kv }, "/old");
|
|
77
|
+
expect(kv.get).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("treats a KV failure as no-redirect, never a throw", async () => {
|
|
81
|
+
// A KV outage must not 5xx a page that would otherwise render fine.
|
|
82
|
+
const kv = { get: vi.fn(() => Promise.reject(new Error("KV down"))) };
|
|
83
|
+
await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("treats a malformed value as no-redirect", async () => {
|
|
87
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: "not json" });
|
|
88
|
+
await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("defaults an unknown status to 302 rather than inventing a permanent redirect", async () => {
|
|
92
|
+
const kv = makeKV({ [`redirect:${ID}:/old`]: '{"to":"/new"}' });
|
|
93
|
+
await expect(lookupExactRedirect(enabled(kv), "/old")).resolves.toMatchObject({ status: 302 });
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("precedence: exact (KV) over glob (memory)", () => {
|
|
98
|
+
it("an exact rule in KV wins over a glob still in the decofile", async () => {
|
|
99
|
+
// This is why matchRedirect was split in two. Doing "matchRedirect, then
|
|
100
|
+
// KV" would return /store/sale here, inverting the precedence every
|
|
101
|
+
// non-fast-deploy path has.
|
|
102
|
+
const map = loadRedirects({
|
|
103
|
+
r: {
|
|
104
|
+
__resolveType: "website/loaders/redirects.ts",
|
|
105
|
+
redirects: [{ from: "/shop/*", to: "/store/*" }],
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const kv = makeKV({ [`redirect:${ID}:/shop/sale`]: '{"to":"/promo","status":301}' });
|
|
109
|
+
|
|
110
|
+
const hit =
|
|
111
|
+
matchExactRedirect("/shop/sale", map) ??
|
|
112
|
+
(await lookupExactRedirect(enabled(kv), "/shop/sale")) ??
|
|
113
|
+
matchPatternRedirect("/shop/sale", map);
|
|
114
|
+
|
|
115
|
+
expect(hit).toMatchObject({ to: "/promo", status: 301 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("falls through to the glob when KV has no exact rule", async () => {
|
|
119
|
+
const map = loadRedirects({
|
|
120
|
+
r: {
|
|
121
|
+
__resolveType: "website/loaders/redirects.ts",
|
|
122
|
+
redirects: [{ from: "/shop/*", to: "/store/*" }],
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const hit =
|
|
126
|
+
matchExactRedirect("/shop/other", map) ??
|
|
127
|
+
(await lookupExactRedirect(enabled(makeKV()), "/shop/other")) ??
|
|
128
|
+
matchPatternRedirect("/shop/other", map);
|
|
129
|
+
|
|
130
|
+
expect(hit).toMatchObject({ to: "/store/other" });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-time lookup for KV-keyed exact redirects.
|
|
3
|
+
*
|
|
4
|
+
* A bulk-migration site can carry tens of thousands of rules. Inside the
|
|
5
|
+
* decofile they are resident in every isolate twice — the parsed snapshot graph
|
|
6
|
+
* and the `RedirectMap` built from it — for data consulted at most once per
|
|
7
|
+
* request and usually matching nothing. `splitExactRedirects` moves them to one
|
|
8
|
+
* `redirect:<deployment-id>:<path>` key each at sync time, so the list is never
|
|
9
|
+
* loaded and this looks up only the path actually being requested.
|
|
10
|
+
*
|
|
11
|
+
* Globs stay in the decofile and in memory: they must be scanned in order
|
|
12
|
+
* against the path, so they can never be a key lookup. There are tens of them.
|
|
13
|
+
*
|
|
14
|
+
* Inert unless fast-deploy is on — without a `DECO_KV` binding there is nowhere
|
|
15
|
+
* to look, and the rules are still in the decofile where `matchRedirect` finds
|
|
16
|
+
* them. Same on-or-off switch as the rest of fast-deploy, so a site can't end
|
|
17
|
+
* up with redirects in neither place.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getDeploymentId, redirectKey, type StoredRedirect } from "@decocms/blocks/cms";
|
|
21
|
+
import type { Redirect } from "@decocms/blocks/sdk/redirects";
|
|
22
|
+
import { getFastDeployKV } from "./kvHydration";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How long a lookup (hit OR miss) is trusted inside one isolate.
|
|
26
|
+
*
|
|
27
|
+
* Deliberately a TTL rather than the decofile revision: the rules now live in
|
|
28
|
+
* their own keys, so a redirect-only content sync leaves the decofile byte
|
|
29
|
+
* identical and the revision poller sees nothing to react to. The TTL is the
|
|
30
|
+
* propagation delay for a redirect edit.
|
|
31
|
+
*
|
|
32
|
+
* Ceiling: if that delay ever needs to be near-zero, the upgrade is a
|
|
33
|
+
* `index:redirects-revision:<id>` key polled next to `index:revision:<id>` —
|
|
34
|
+
* not a shorter TTL, which just multiplies KV reads.
|
|
35
|
+
*/
|
|
36
|
+
const TTL_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/** Bounded so a crawler walking unique URLs cannot grow the isolate unbounded —
|
|
39
|
+
* the whole point of this module is to stop holding an unbounded list. */
|
|
40
|
+
const MAX_ENTRIES = 2_000;
|
|
41
|
+
|
|
42
|
+
interface CacheEntry {
|
|
43
|
+
value: StoredRedirect | null;
|
|
44
|
+
expiresAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// globalThis-backed so every Vite server-function split-module copy shares one
|
|
48
|
+
// cache (same reason as `loader.ts` and `kvHydration.ts`).
|
|
49
|
+
const G = globalThis as unknown as { __decoRedirectCache?: Map<string, CacheEntry> };
|
|
50
|
+
function cache(): Map<string, CacheEntry> {
|
|
51
|
+
G.__decoRedirectCache ??= new Map();
|
|
52
|
+
return G.__decoRedirectCache;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Insertion-ordered LRU touch + evict. */
|
|
56
|
+
function remember(key: string, value: StoredRedirect | null): void {
|
|
57
|
+
const c = cache();
|
|
58
|
+
c.delete(key);
|
|
59
|
+
c.set(key, { value, expiresAt: Date.now() + TTL_MS });
|
|
60
|
+
while (c.size > MAX_ENTRIES) c.delete(c.keys().next().value!);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Cached entry, or `undefined` when absent/expired. */
|
|
64
|
+
function peek(key: string): CacheEntry | undefined {
|
|
65
|
+
const c = cache();
|
|
66
|
+
const entry = c.get(key);
|
|
67
|
+
if (!entry) return undefined;
|
|
68
|
+
if (Date.now() >= entry.expiresAt) {
|
|
69
|
+
c.delete(key);
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
c.delete(key);
|
|
73
|
+
c.set(key, entry);
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test seam — the cache is process-global, so suites must be able to reset it. */
|
|
78
|
+
export function clearRedirectCache(): void {
|
|
79
|
+
G.__decoRedirectCache = new Map();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Look up one exact redirect for `normalizedPath` (already through
|
|
84
|
+
* `normalizePath` — the key contract).
|
|
85
|
+
*
|
|
86
|
+
* Returns `null` for "no redirect", including every case where the lookup
|
|
87
|
+
* cannot run (fast-deploy off, no deployment id, KV error). A KV failure must
|
|
88
|
+
* not 5xx a page that would otherwise render: the worst outcome is the request
|
|
89
|
+
* proceeds and the URL serves its normal response.
|
|
90
|
+
*/
|
|
91
|
+
export async function lookupExactRedirect(
|
|
92
|
+
env: Record<string, unknown>,
|
|
93
|
+
normalizedPath: string,
|
|
94
|
+
): Promise<Redirect | null> {
|
|
95
|
+
const kv = getFastDeployKV(env);
|
|
96
|
+
if (!kv) return null;
|
|
97
|
+
|
|
98
|
+
const deploymentId = getDeploymentId(env);
|
|
99
|
+
if (!deploymentId) return null;
|
|
100
|
+
|
|
101
|
+
const key = redirectKey(deploymentId, normalizedPath);
|
|
102
|
+
const cached = peek(key);
|
|
103
|
+
const stored = cached !== undefined ? cached.value : await read(kv, key);
|
|
104
|
+
if (cached === undefined) remember(key, stored);
|
|
105
|
+
if (!stored) return null;
|
|
106
|
+
|
|
107
|
+
return { from: normalizedPath, to: stored.to, status: stored.status };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function read(
|
|
111
|
+
kv: { get(key: string): Promise<string | null> },
|
|
112
|
+
key: string,
|
|
113
|
+
): Promise<StoredRedirect | null> {
|
|
114
|
+
try {
|
|
115
|
+
const raw = await kv.get(key);
|
|
116
|
+
if (raw === null) return null;
|
|
117
|
+
const parsed = JSON.parse(raw) as StoredRedirect;
|
|
118
|
+
// A malformed value is a miss, not a throw — never let bad data in one key
|
|
119
|
+
// take down every request for that path.
|
|
120
|
+
if (!parsed || typeof parsed.to !== "string") return null;
|
|
121
|
+
return { to: parsed.to, status: parsed.status === 301 ? 301 : 302 };
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -76,7 +76,13 @@ import {
|
|
|
76
76
|
} from "@decocms/blocks/sdk/otel";
|
|
77
77
|
import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
|
|
78
78
|
import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
loadRedirects,
|
|
81
|
+
matchExactRedirect,
|
|
82
|
+
matchPatternRedirect,
|
|
83
|
+
normalizePath,
|
|
84
|
+
type RedirectMap,
|
|
85
|
+
} from "@decocms/blocks/sdk/redirects";
|
|
80
86
|
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
81
87
|
import { createResponseCache } from "@decocms/blocks/sdk/responseCache";
|
|
82
88
|
import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
|
|
@@ -96,6 +102,7 @@ import {
|
|
|
96
102
|
requestCarriesDraft,
|
|
97
103
|
} from "./draft";
|
|
98
104
|
import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
|
|
105
|
+
import { lookupExactRedirect } from "./kvRedirects";
|
|
99
106
|
import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
|
|
100
107
|
import { type SpeculationRulesConfig, setSpeculationRules } from "./speculationRules";
|
|
101
108
|
|
|
@@ -2192,7 +2199,15 @@ export function createDecoWorkerEntry(
|
|
|
2192
2199
|
_redirectMap = loadRedirects(loadBlocks());
|
|
2193
2200
|
_redirectMapRevision = currentRevision;
|
|
2194
2201
|
}
|
|
2195
|
-
|
|
2202
|
+
// Exact before glob, across BOTH sources. On a fast-deploy site the exact
|
|
2203
|
+
// rules live under their own `redirect:<id>:<path>` KV keys and are absent
|
|
2204
|
+
// from the decofile, so the in-memory exact hit is the non-fast-deploy
|
|
2205
|
+
// case. Going straight to `matchRedirect` and only then to KV would let a
|
|
2206
|
+
// glob win over an exact rule, inverting the precedence.
|
|
2207
|
+
const cmsRedirect =
|
|
2208
|
+
matchExactRedirect(url.pathname, _redirectMap!) ??
|
|
2209
|
+
(await lookupExactRedirect(env as Record<string, unknown>, normalizePath(url.pathname))) ??
|
|
2210
|
+
matchPatternRedirect(url.pathname, _redirectMap!);
|
|
2196
2211
|
if (cmsRedirect) {
|
|
2197
2212
|
return new Response(null, {
|
|
2198
2213
|
status: cmsRedirect.status,
|
package/src/vite/plugin.js
CHANGED
|
@@ -173,27 +173,52 @@ export function generateSchemaArgs(siteName) {
|
|
|
173
173
|
return ["--site", siteName];
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Env var a deploy pipeline sets to declare "I seed `decofile:<deployment-id>`
|
|
178
|
+
* into KV BEFORE activating this version". Set by the cfworkers-builder under
|
|
179
|
+
* exactly the condition that guards its seed step, so the stub below and the
|
|
180
|
+
* seed can never drift apart.
|
|
181
|
+
*/
|
|
182
|
+
const SEEDED_DEPLOY_ENV = "DECO_SEEDED_DEPLOY";
|
|
183
|
+
|
|
184
|
+
/** Whether this build runs inside a pipeline that seeds KV before activation. */
|
|
185
|
+
function isSeededDeployPipeline() {
|
|
186
|
+
const v = process.env[SEEDED_DEPLOY_ENV];
|
|
187
|
+
return v === "1" || v === "true";
|
|
188
|
+
}
|
|
189
|
+
|
|
176
190
|
/**
|
|
177
191
|
* @param {object} [opts]
|
|
178
|
-
* @param {boolean} [opts.fastDeploy=
|
|
179
|
-
* bundle too, so the worker's only copy of the decofile is the one
|
|
180
|
-
* from KV at cold start.
|
|
192
|
+
* @param {boolean | "auto"} [opts.fastDeploy="auto"] Stub `blocks.gen` out of
|
|
193
|
+
* the SERVER bundle too, so the worker's only copy of the decofile is the one
|
|
194
|
+
* hydrated from KV at cold start.
|
|
181
195
|
*
|
|
182
196
|
* Without this, a fast-deploy site holds the decofile TWICE: the bundled
|
|
183
197
|
* `JSON.parse(...)` graph stays permanently reachable through the site's
|
|
184
198
|
* `import { blocks } from ".deco/blocks.gen"` module binding, and
|
|
185
199
|
* `ensureBlocksHydrated` adds a second graph via `setBlocks()`. For a 10MB
|
|
186
|
-
* decofile that is tens of MB of avoidable isolate memory
|
|
200
|
+
* decofile that is tens of MB of avoidable isolate memory, on a platform
|
|
201
|
+
* whose per-isolate cap is 128MB and has no GC knob.
|
|
202
|
+
*
|
|
203
|
+
* It is gated because it removes the fallback: with no bundled snapshot, a
|
|
204
|
+
* cold start that cannot read KV has no content to serve, and
|
|
205
|
+
* `ensureBlocksHydrated` fails the request loudly (5xx) rather than serving
|
|
206
|
+
* an empty site that the edge would then cache — see `kvHydration.ts`. That
|
|
207
|
+
* is only safe when the deploy pipeline seeds `decofile:<deployment-id>`
|
|
208
|
+
* BEFORE the new version activates.
|
|
187
209
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* `
|
|
193
|
-
* `
|
|
210
|
+
* `"auto"` (default) stubs only when the pipeline declares it seeds, via
|
|
211
|
+
* `DECO_SEEDED_DEPLOY`. A site building through Cloudflare Workers Builds or
|
|
212
|
+
* a manual `wrangler deploy` never sets it, so it keeps the bundled snapshot
|
|
213
|
+
* and behaves exactly as before — no site-side config, no way to half-enable
|
|
214
|
+
* it. `true` forces the stub, `false` disables it. A stubbed bundle also
|
|
215
|
+
* needs `DECO_FAST_DEPLOY=1` and a `DECO_KV` binding at runtime — without
|
|
216
|
+
* them `ensureBlocksHydrated` fails every request rather than serve `{}`.
|
|
194
217
|
* @returns {import("vite").PluginOption}
|
|
195
218
|
*/
|
|
196
|
-
export function decoVitePlugin({ fastDeploy =
|
|
219
|
+
export function decoVitePlugin({ fastDeploy = "auto" } = {}) {
|
|
220
|
+
// Resolved once: the pipeline's answer cannot change mid-build.
|
|
221
|
+
const stubBlocks = fastDeploy === "auto" ? isSeededDeployPipeline() : fastDeploy === true;
|
|
197
222
|
// Set from the `config` hook (which Vite always runs before `load`). The
|
|
198
223
|
// fastDeploy stub must apply to production builds ONLY: `vite dev` has no KV
|
|
199
224
|
// to hydrate from, and the dev bootstrap below deliberately relies on reading
|
|
@@ -239,7 +264,7 @@ export function decoVitePlugin({ fastDeploy = false } = {}) {
|
|
|
239
264
|
// `import { blocks }` binding keeps the bundled graph alive for the
|
|
240
265
|
// isolate's whole life, even after `setBlocks()` swaps in the KV one).
|
|
241
266
|
// Build only — see `isBuild` above.
|
|
242
|
-
if (
|
|
267
|
+
if (stubBlocks && isBuild) {
|
|
243
268
|
return "export const blocks = {};";
|
|
244
269
|
}
|
|
245
270
|
|
|
@@ -785,7 +810,7 @@ export function decoVitePlugin({ fastDeploy = false } = {}) {
|
|
|
785
810
|
// is not equivalent — draft-preview overrides can make a stubbed bundle
|
|
786
811
|
// look populated, and a legitimately-empty decofile (new site) would
|
|
787
812
|
// look stubbed.
|
|
788
|
-
__DECO_BLOCKS_STUBBED__: JSON.stringify(
|
|
813
|
+
__DECO_BLOCKS_STUBBED__: JSON.stringify(stubBlocks && command === "build"),
|
|
789
814
|
};
|
|
790
815
|
|
|
791
816
|
// Only split chunks for production builds — dev uses unbundled ESM.
|
package/src/vite/plugin.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { decoVitePlugin } from "./plugin.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -68,6 +68,12 @@ describe("decoVitePlugin — fastDeploy server stub", () => {
|
|
|
68
68
|
const id = "/repo/.deco/blocks.gen.ts";
|
|
69
69
|
const STUB = "export const blocks = {};";
|
|
70
70
|
|
|
71
|
+
// The default is "auto", which reads DECO_SEEDED_DEPLOY. Pin it off so the
|
|
72
|
+
// "default does not stub" assertions below can't be silently satisfied (or
|
|
73
|
+
// broken) by an ambient env var.
|
|
74
|
+
vi.stubEnv("DECO_SEEDED_DEPLOY", "");
|
|
75
|
+
afterEach(() => vi.stubEnv("DECO_SEEDED_DEPLOY", ""));
|
|
76
|
+
|
|
71
77
|
/** @param {{fastDeploy?: boolean}} opts @param {"build"|"serve"} command */
|
|
72
78
|
const make = (opts, command) => {
|
|
73
79
|
const p = /** @type {any} */ (decoVitePlugin(opts));
|
|
@@ -110,3 +116,61 @@ describe("decoVitePlugin — fastDeploy server stub", () => {
|
|
|
110
116
|
expect(def.define.__DECO_BLOCKS_STUBBED__).toBe("false");
|
|
111
117
|
});
|
|
112
118
|
});
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The `"auto"` default: stub only when the deploy pipeline declares it seeds
|
|
122
|
+
* `decofile:<deployment-id>` into KV BEFORE activating the new version.
|
|
123
|
+
*
|
|
124
|
+
* Stubbing without that guarantee is a hard outage, not a degraded mode — the
|
|
125
|
+
* bundle has no decofile, so `ensureBlocksHydrated` 5xxs on purpose rather than
|
|
126
|
+
* serving an empty site the edge would cache. Cloudflare Workers Builds and a
|
|
127
|
+
* manual `wrangler deploy` do not seed, and they never set the env.
|
|
128
|
+
*/
|
|
129
|
+
describe("decoVitePlugin — fastDeploy auto gate", () => {
|
|
130
|
+
const id = "/repo/.deco/blocks.gen.ts";
|
|
131
|
+
const STUB = "export const blocks = {};";
|
|
132
|
+
|
|
133
|
+
afterEach(() => vi.unstubAllEnvs());
|
|
134
|
+
|
|
135
|
+
/** @param {"build"|"serve"} command */
|
|
136
|
+
const makeDefault = (command) => {
|
|
137
|
+
const p = /** @type {any} */ (decoVitePlugin());
|
|
138
|
+
const cfg = p.config({}, { command });
|
|
139
|
+
return { plugin: p, cfg };
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
it("does not stub when the pipeline does not declare a seed step", () => {
|
|
143
|
+
vi.stubEnv("DECO_SEEDED_DEPLOY", "");
|
|
144
|
+
const { plugin, cfg } = makeDefault("build");
|
|
145
|
+
expect(plugin.load(id, { ssr: true })).not.toBe(STUB);
|
|
146
|
+
expect(cfg.define.__DECO_BLOCKS_STUBBED__).toBe("false");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("stubs on a production build when the pipeline declares it seeds", () => {
|
|
150
|
+
vi.stubEnv("DECO_SEEDED_DEPLOY", "1");
|
|
151
|
+
const { plugin, cfg } = makeDefault("build");
|
|
152
|
+
expect(plugin.load(id, { ssr: true })).toBe(STUB);
|
|
153
|
+
expect(cfg.define.__DECO_BLOCKS_STUBBED__).toBe("true");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("still never stubs SSR in dev, even inside a seeding pipeline", () => {
|
|
157
|
+
vi.stubEnv("DECO_SEEDED_DEPLOY", "1");
|
|
158
|
+
const { plugin, cfg } = makeDefault("serve");
|
|
159
|
+
expect(plugin.load(id, { ssr: true })).not.toBe(STUB);
|
|
160
|
+
expect(cfg.define.__DECO_BLOCKS_STUBBED__).toBe("false");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("explicit false wins over a seeding pipeline", () => {
|
|
164
|
+
vi.stubEnv("DECO_SEEDED_DEPLOY", "1");
|
|
165
|
+
const p = /** @type {any} */ (decoVitePlugin({ fastDeploy: false }));
|
|
166
|
+
const cfg = p.config({}, { command: "build" });
|
|
167
|
+
expect(p.load(id, { ssr: true })).not.toBe(STUB);
|
|
168
|
+
expect(cfg.define.__DECO_BLOCKS_STUBBED__).toBe("false");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("explicit true wins over a non-seeding pipeline", () => {
|
|
172
|
+
vi.stubEnv("DECO_SEEDED_DEPLOY", "");
|
|
173
|
+
const p = /** @type {any} */ (decoVitePlugin({ fastDeploy: true }));
|
|
174
|
+
expect(p.config({}, { command: "build" }).define.__DECO_BLOCKS_STUBBED__).toBe("true");
|
|
175
|
+
});
|
|
176
|
+
});
|