@decocms/tanstack 8.0.0 → 8.1.0-next.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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -0,0 +1,100 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Docs conformance, Workers binding: hosted.mdx ("Release checks on Workers
4
+ * run after the response, inside ctx.waitUntil, on their own"),
5
+ * hosted-releases-internals.mdx (idle scheduling on Workers) and
6
+ * hosted-publishing.mdx ("Large content on Workers": kvLoader as `content`).
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { createCMS, resetForTests } from "@decocms/blocks";
12
+ import { computeContentRevision } from "@decocms/blocks/protocol";
13
+ import { afterEach, describe, expect, it, vi } from "vitest";
14
+ import { kvLoader } from "../kvLoader";
15
+
16
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
17
+ const WORKSPACE = path.resolve(HERE, "../../../../..");
18
+ const ORIGIN = "https://delivery.decocms.com";
19
+
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals();
22
+ vi.restoreAllMocks();
23
+ resetForTests();
24
+ });
25
+
26
+ const seo = (props: { title: string; description: string }) => props;
27
+
28
+ describe("hosted-publishing: large content on Workers", () => {
29
+ it("HP-14: kvLoader as content; a missing key fails reads until the first release check", async () => {
30
+ const blocks = { SummerSEO: { __resolveType: "seo", title: "Released", description: "d" } };
31
+ const revision = await computeContentRevision(blocks);
32
+ const asset = `/sites/acme/revisions/${revision}.json`;
33
+ vi.stubGlobal(
34
+ "fetch",
35
+ vi.fn(async (input: string | URL | Request) => {
36
+ const url = String(input);
37
+ if (url === `${ORIGIN}/sites/acme/channels/production.json`) {
38
+ return Response.json({ format: 1, generation: 1, revision, snapshot: asset });
39
+ }
40
+ if (url === `${ORIGIN}${asset}`) return Response.json({ revision, blocks });
41
+ return new Response("not found", { status: 404 });
42
+ }),
43
+ );
44
+ const namespace = { get: vi.fn(async () => null) }; // the deploy didn't write the key
45
+ const cms = createCMS({
46
+ blocks: { seo },
47
+ content: kvLoader(namespace, { key: "decofile:abc" }),
48
+ site: "acme",
49
+ token: "t",
50
+ });
51
+ const [, error] = await cms.forRelease().resolve("SummerSEO");
52
+ expect(error?.code).toBe("LOADER_FAILED");
53
+ expect(await cms.update()).toEqual({ updated: true });
54
+ const [value, after] = await cms.forRelease().resolve<{ title: string }>("SummerSEO");
55
+ expect(after).toBeNull();
56
+ expect(value?.title).toBe("Released");
57
+ });
58
+ });
59
+
60
+ describe("hosted: release checks on Workers", () => {
61
+ it("H-10/HRI-4: the documented Workers setup runs due checks inside ctx.waitUntil, with no user code", () => {
62
+ // The documented TanStack Start setup (tanstack-start-descriptors.mdx,
63
+ // examples/tanstack-smoke) runs Start's own server entry:
64
+ const wrangler = fs.readFileSync(
65
+ path.join(WORKSPACE, "examples/tanstack-smoke/wrangler.jsonc"),
66
+ "utf8",
67
+ );
68
+ expect(wrangler).toContain('"main": "@tanstack/react-start/server-entry"');
69
+ // So something in the next-major surface (core or binding) must route the
70
+ // core's background work into ctx.waitUntil on its own: install the
71
+ // `Symbol.for("decocms.blocks.background")` hook, or use Workers'
72
+ // `waitUntil` from "cloudflare:workers". Today only the v7
73
+ // `createDecoWorkerEntry` (sdk/workerEntry.ts) installs that hook, and the
74
+ // documented setup never runs it, so checks fall back to a timer.
75
+ const roots = [
76
+ path.join(WORKSPACE, "packages/blocks/src/v8"),
77
+ path.join(WORKSPACE, "packages/tanstack/src/v8"),
78
+ ];
79
+ const installers: string[] = [];
80
+ const walk = (dir: string) => {
81
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
82
+ const full = path.join(dir, entry.name);
83
+ if (entry.isDirectory()) {
84
+ if (entry.name !== "__conformance__") walk(full);
85
+ } else if (/\.tsx?$/.test(entry.name) && !/\.test\.tsx?$/.test(entry.name)) {
86
+ const text = fs.readFileSync(full, "utf8");
87
+ if (
88
+ /installBackgroundHook|from "cloudflare:workers"|\[(BACKGROUND_HOOK|HOOK)\]\s*=(?!=)/.test(
89
+ text,
90
+ )
91
+ ) {
92
+ installers.push(path.relative(WORKSPACE, full));
93
+ }
94
+ }
95
+ }
96
+ };
97
+ for (const dir of roots) walk(dir);
98
+ expect(installers).not.toEqual([]);
99
+ });
100
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What `@decocms/tanstack` adds to a next-major site on Cloudflare Workers
3
+ * (see /next/caching#upstream-data): GETs made through
4
+ * `createInstrumentedFetch` are served from the Cloudflare Cache API,
5
+ * honouring the upstream's `cache-control`, and bypass it while a draft is
6
+ * rendered. Hits carry the instrumented fetch's `cached=true` label.
7
+ *
8
+ * It reaches the core through a `Symbol.for` hook on `globalThis`
9
+ * (`decocms.blocks.upstreamCache`; the core imports nothing from a binding).
10
+ * Nothing here is exported from the package; the worker entry calls it.
11
+ */
12
+ import { draftPointer } from "@decocms/blocks";
13
+ // The worker entry runs every request inside RequestContext, whose storage
14
+ // resolves to a no-op stub in browser bundles (this module is reachable from
15
+ // the package root, which client code imports).
16
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
17
+
18
+ const UPSTREAM_CACHE = Symbol.for("decocms.blocks.upstreamCache");
19
+
20
+ /** Whether the request being served renders a draft (then upstream caching is off). */
21
+ function renderingDraft(): boolean {
22
+ try {
23
+ return draftPointer(RequestContext.request) !== null;
24
+ } catch {
25
+ return false; // outside a request
26
+ }
27
+ }
28
+
29
+ /** Whether a response may be kept in a shared cache, per its `cache-control`. */
30
+ function cacheable(response: Response): boolean {
31
+ if (response.status !== 200) return false;
32
+ const control = response.headers.get("cache-control")?.toLowerCase() ?? "";
33
+ if (/\b(no-store|no-cache|private)\b/.test(control)) return false;
34
+ return /\b(s-maxage|max-age)=[1-9]/.test(control);
35
+ }
36
+
37
+ /**
38
+ * Installs the upstream cache (idempotent). Requests that carry a user's
39
+ * credentials (`authorization`, `cookie`) are never cached, and nothing is
40
+ * read or written while a draft is rendered.
41
+ */
42
+ export function installUpstreamCache(): void {
43
+ const g = globalThis as Record<symbol, unknown>;
44
+ if (typeof g[UPSTREAM_CACHE] === "function") return;
45
+ g[UPSTREAM_CACHE] = async (
46
+ input: string | URL | Request,
47
+ init: RequestInit,
48
+ fetch: typeof globalThis.fetch,
49
+ ): Promise<{ response: Response; cached: boolean }> => {
50
+ const cache = (globalThis as { caches?: { default?: Cache } }).caches?.default;
51
+ const request = new Request(input, init);
52
+ if (
53
+ cache === undefined ||
54
+ renderingDraft() ||
55
+ request.headers.has("authorization") ||
56
+ request.headers.has("cookie")
57
+ ) {
58
+ return { response: await fetch(input, init), cached: false };
59
+ }
60
+ const hit = await cache.match(request.url).catch(() => undefined);
61
+ if (hit) return { response: hit, cached: true };
62
+ const response = await fetch(input, init);
63
+ if (cacheable(response)) {
64
+ // Awaited: the Cache API is local to the data center, and the next
65
+ // request for the same URL should already find it.
66
+ await cache.put(request.url, response.clone()).catch(() => {});
67
+ }
68
+ return { response, cached: false };
69
+ };
70
+ }
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const here = dirname(fileURLToPath(import.meta.url));
7
+ // The esbuild CLI, not its JS API: the API breaks across Vitest's realm
8
+ // boundary (see packages/blocks/src/cms/client.browserBundle.test.ts).
9
+ const esbuildBin = join(here, "../../../../node_modules/.bin/esbuild");
10
+
11
+ /**
12
+ * The docs import `kvLoader` from the package root, which still re-exports
13
+ * the v7 binding. `"sideEffects"` in package.json lets a bundler drop every
14
+ * root module the site doesn't use; without it the Worker ships the v7
15
+ * runtime (react-dom/server, Start server-core, OpenTelemetry...).
16
+ */
17
+ describe("kvLoader bundle", () => {
18
+ it("imports from the package root without pulling in the v7 binding", () => {
19
+ const output = execFileSync(
20
+ esbuildBin,
21
+ [
22
+ "--bundle",
23
+ "--format=esm",
24
+ "--platform=neutral",
25
+ "--minify",
26
+ "--main-fields=module,main",
27
+ "--external:react",
28
+ "--external:react-dom",
29
+ "--external:@tanstack/*",
30
+ "--external:node:*",
31
+ "--log-level=error",
32
+ ],
33
+ {
34
+ cwd: join(here, "../.."),
35
+ input: 'export { kvLoader } from "@decocms/tanstack";',
36
+ encoding: "utf-8",
37
+ },
38
+ );
39
+ expect(output).toContain("kvLoader");
40
+ expect(output.length).toBeLessThan(2048);
41
+ });
42
+ });
@@ -0,0 +1,74 @@
1
+ import { createCMS, resetForTests, type Snapshot } from "@decocms/blocks";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { kvLoader } from "./kvLoader";
4
+
5
+ const snapshot: Snapshot = {
6
+ revision: "r1",
7
+ blocks: { Hello: { __resolveType: "greeting", name: "world" } },
8
+ };
9
+
10
+ function kv(values: Record<string, unknown>) {
11
+ return { get: vi.fn(async (key: string, _type: "json") => values[key] ?? null) };
12
+ }
13
+
14
+ afterEach(() => resetForTests());
15
+
16
+ describe("kvLoader", () => {
17
+ it("reads the snapshot under its key once per isolate", async () => {
18
+ const namespace = kv({ "decofile:abc": snapshot });
19
+ const loader = kvLoader(namespace, { key: "decofile:abc" });
20
+ expect(await loader.load()).toEqual(snapshot);
21
+ expect(await loader.load("api.deco.example/drafts/x@v1")).toEqual(snapshot);
22
+ expect(namespace.get).toHaveBeenCalledTimes(1);
23
+ expect(namespace.get).toHaveBeenCalledWith("decofile:abc", "json");
24
+ expect(loader.update).toBeUndefined();
25
+ });
26
+
27
+ it("shares one in-flight read between concurrent loads", async () => {
28
+ let release!: (value: unknown) => void;
29
+ const namespace = { get: vi.fn(() => new Promise((resolve) => (release = resolve))) };
30
+ const loader = kvLoader(namespace, { key: "content" });
31
+ const first = loader.load();
32
+ const second = loader.load();
33
+ release(snapshot);
34
+ expect(await first).toEqual(snapshot);
35
+ expect(await second).toEqual(snapshot);
36
+ expect(namespace.get).toHaveBeenCalledTimes(1);
37
+ });
38
+
39
+ it("fails on a missing key and reads KV again on the next call", async () => {
40
+ const values: Record<string, unknown> = {};
41
+ const namespace = kv(values);
42
+ const loader = kvLoader(namespace, { key: "decofile:abc" });
43
+ await expect(loader.load()).rejects.toThrow(/decofile:abc/);
44
+ values["decofile:abc"] = snapshot;
45
+ expect(await loader.load()).toEqual(snapshot);
46
+ expect(namespace.get).toHaveBeenCalledTimes(2);
47
+ });
48
+
49
+ it("serves a CMS without reading KV per request", async () => {
50
+ const namespace = kv({ content: snapshot });
51
+ const cms = createCMS({
52
+ blocks: { greeting: (input: { name: string }) => `Hello, ${input.name}` },
53
+ content: kvLoader(namespace, { key: "content" }),
54
+ });
55
+ expect(await cms.forRelease().resolve("Hello")).toEqual(["Hello, world", null]);
56
+ expect(await cms.forRelease().resolve("Hello")).toEqual(["Hello, world", null]);
57
+ expect(namespace.get).toHaveBeenCalledTimes(1);
58
+ });
59
+
60
+ it("surfaces a missing key as LOADER_FAILED", async () => {
61
+ const cms = createCMS({ blocks: {}, content: kvLoader(kv({}), { key: "content" }) });
62
+ const [, error] = await cms.forRelease().list("page");
63
+ expect(error?.code).toBe("LOADER_FAILED");
64
+ });
65
+
66
+ it("surfaces a value that isn't a snapshot as LOADER_FAILED", async () => {
67
+ const cms = createCMS({
68
+ blocks: {},
69
+ content: kvLoader(kv({ content: {} }), { key: "content" }),
70
+ });
71
+ const [, error] = await cms.forRelease().list("page");
72
+ expect(error?.code).toBe("LOADER_FAILED");
73
+ });
74
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `kvLoader(namespace, { key })`: the content snapshot your deploy stored in a
3
+ * Workers KV namespace, for sites whose content is too large to bundle. See
4
+ * /next/api-reference#loaders and /next/hosted-publishing#large-content-on-workers.
5
+ */
6
+ import type { Loader, Snapshot } from "@decocms/blocks";
7
+
8
+ /** The one KV method this loader calls; a Workers `KVNamespace` binding fits it. */
9
+ export interface KVNamespaceLike {
10
+ get(key: string, type: "json"): Promise<unknown>;
11
+ }
12
+
13
+ /**
14
+ * Reads the snapshot under `key` once per isolate and keeps it. It ignores
15
+ * draft pointers and has no `update()`: the deploy writes the key before the
16
+ * Worker takes traffic, and it doesn't change while the Worker runs. A missing
17
+ * key or a failed read isn't kept, so the next request reads KV again.
18
+ *
19
+ * The kept read is a promise shared across requests, so the Worker needs the
20
+ * `no_handle_cross_request_promise_resolution` compatibility flag, as the CMS
21
+ * itself does (see /next/tanstack-start-descriptors).
22
+ */
23
+ export function kvLoader(namespace: KVNamespaceLike, options: { key: string }): Loader {
24
+ const { key } = options;
25
+ let snapshot: Promise<Snapshot> | undefined;
26
+ return {
27
+ load() {
28
+ if (snapshot === undefined) {
29
+ const pending = namespace.get(key, "json").then((value) => {
30
+ if (value === null || value === undefined) {
31
+ throw new Error(`kvLoader: no content snapshot under the KV key "${key}"`);
32
+ }
33
+ return value as Snapshot;
34
+ });
35
+ snapshot = pending;
36
+ pending.catch(() => {
37
+ if (snapshot === pending) snapshot = undefined;
38
+ });
39
+ }
40
+ return snapshot;
41
+ },
42
+ };
43
+ }
@@ -84,6 +84,12 @@ function resolveBuildHash() {
84
84
  return Date.now().toString(36);
85
85
  }
86
86
 
87
+ // Virtual id for meta.gen.json on the server: the real path wrapped so the id
88
+ // does NOT end in `.json` (Vite's `vite:json` would re-parse our JS output).
89
+ const META_GEN_PREFIX = "\0deco-meta:";
90
+ const META_GEN_SUFFIX = ".deco-meta.js";
91
+ const metaGenSsrId = (file) => META_GEN_PREFIX + file + META_GEN_SUFFIX;
92
+
87
93
  // Bare-specifier stubs resolved by ID before Vite touches them.
88
94
  /** @type {Record<string, string>} */
89
95
  const CLIENT_STUBS = {
@@ -154,8 +160,78 @@ const STUB_SOURCE = {
154
160
  "\0stub:bare-fs": "export const promises = {}; export default { promises };",
155
161
  };
156
162
 
157
- /** @returns {import("vite").PluginOption} */
158
- export function decoVitePlugin() {
163
+ /**
164
+ * Argv (after the generate-schema.ts script path) for a meta.gen.json run.
165
+ *
166
+ * generate-schema ALWAYS composes before writing, so the emitted meta.gen.json
167
+ * carries every framework `website/*` block (Page, matchers, __SECTION_REF__,
168
+ * Resolvable). This matters because meta.gen.json is committed and Studio reads
169
+ * it as-is (no composeMeta at read time) — an uncomposed rewrite would silently
170
+ * break the admin editors, e.g. the date matcher powering variant rules
171
+ * disappearing (a production storefront regression). Mirrors `npm run generate`'s schema
172
+ * step. Both invocation sites in configureServer derive their args here so they
173
+ * can never drift apart.
174
+ *
175
+ * @param {string} siteName
176
+ * @returns {string[]}
177
+ */
178
+ export function generateSchemaArgs(siteName) {
179
+ return ["--site", siteName];
180
+ }
181
+
182
+ /**
183
+ * Env var a deploy pipeline sets to declare "I seed `decofile:<deployment-id>`
184
+ * into KV BEFORE activating this version". Set by the cfworkers-builder under
185
+ * exactly the condition that guards its seed step, so the stub below and the
186
+ * seed can never drift apart.
187
+ */
188
+ const SEEDED_DEPLOY_ENV = "DECO_SEEDED_DEPLOY";
189
+
190
+ /** Whether this build runs inside a pipeline that seeds KV before activation. */
191
+ function isSeededDeployPipeline() {
192
+ const v = process.env[SEEDED_DEPLOY_ENV];
193
+ return v === "1" || v === "true";
194
+ }
195
+
196
+ /**
197
+ * @param {object} [opts]
198
+ * @param {boolean | "auto"} [opts.fastDeploy="auto"] Stub `blocks.gen` out of
199
+ * the SERVER bundle too, so the worker's only copy of the decofile is the one
200
+ * hydrated from KV at cold start.
201
+ *
202
+ * Without this, a fast-deploy site holds the decofile TWICE: the bundled
203
+ * `JSON.parse(...)` graph stays permanently reachable through the site's
204
+ * `import { blocks } from ".deco/blocks.gen"` module binding, and
205
+ * `ensureBlocksHydrated` adds a second graph via `setBlocks()`. For a 10MB
206
+ * decofile that is tens of MB of avoidable isolate memory, on a platform
207
+ * whose per-isolate cap is 128MB and has no GC knob.
208
+ *
209
+ * It is gated because it removes the fallback: with no bundled snapshot, a
210
+ * cold start that cannot read KV has no content to serve, and
211
+ * `ensureBlocksHydrated` fails the request loudly (5xx) rather than serving
212
+ * an empty site that the edge would then cache — see `kvHydration.ts`. That
213
+ * is only safe when the deploy pipeline seeds `decofile:<deployment-id>`
214
+ * BEFORE the new version activates.
215
+ *
216
+ * `"auto"` (default) stubs only when the pipeline declares it seeds, via
217
+ * `DECO_SEEDED_DEPLOY`. A site building through Cloudflare Workers Builds or
218
+ * a manual `wrangler deploy` never sets it, so it keeps the bundled snapshot
219
+ * and behaves exactly as before — no site-side config, no way to half-enable
220
+ * it. `true` forces the stub, `false` disables it. A stubbed bundle also
221
+ * needs `DECO_FAST_DEPLOY=1` and a `DECO_KV` binding at runtime — without
222
+ * them `ensureBlocksHydrated` fails every request rather than serve `{}`.
223
+ * @returns {import("vite").PluginOption}
224
+ */
225
+ export function decoVitePlugin({ fastDeploy = "auto" } = {}) {
226
+ // Resolved once: the pipeline's answer cannot change mid-build.
227
+ const stubBlocks = fastDeploy === "auto" ? isSeededDeployPipeline() : fastDeploy === true;
228
+ // Set from the `config` hook (which Vite always runs before `load`). The
229
+ // fastDeploy stub must apply to production builds ONLY: `vite dev` has no KV
230
+ // to hydrate from, and the dev bootstrap below deliberately relies on reading
231
+ // the `.json` sibling that the stub bypasses — stubbing there blanks the
232
+ // local site.
233
+ let isBuild = false;
234
+
159
235
  /** @type {import("vite").Plugin} */
160
236
  const plugin = {
161
237
  name: "deco-server-only-stubs",
@@ -165,6 +241,32 @@ export function decoVitePlugin() {
165
241
  // SSR-only stubs — must be checked first since the client guard below
166
242
  // returns undefined for everything that hasn't matched yet on SSR.
167
243
  if (options?.ssr && SSR_STUBS[id]) return SSR_STUBS[id];
244
+ // meta.gen.json on the SERVER → a virtual id that does NOT end in
245
+ // `.json`. `load` below emits JS (`JSON.parse("...")`), and Vite's
246
+ // `vite:json` transform runs on every id matching `\.json$` regardless
247
+ // of who loaded it — it would try to JSON.parse that JS and fail the
248
+ // build ("Failed to parse JSON file").
249
+ if (options?.ssr && importer && id.endsWith("meta.gen.json")) {
250
+ if (id.startsWith(".") || path.isAbsolute(id)) {
251
+ const file = path.resolve(path.dirname(importer), id);
252
+ if (existsSync(file)) return metaGenSsrId(file);
253
+ // Absent (pre-generate-schema) — let Vite report it normally.
254
+ return undefined;
255
+ }
256
+ // Alias / bare specifier (`~/.deco/meta.gen.json`, tsconfig paths…):
257
+ // let the rest of the pipeline resolve it, then wrap — otherwise it
258
+ // falls through to vite:json and loses the JSON.parse optimization.
259
+ if (typeof this?.resolve === "function") {
260
+ return this.resolve(id, importer, { ...options, skipSelf: true }).then(
261
+ (r) =>
262
+ r && !r.external && r.id.endsWith("meta.gen.json") && existsSync(r.id)
263
+ ? metaGenSsrId(r.id)
264
+ : undefined,
265
+ // A resolver in the chain threw — let Vite's normal resolution report it.
266
+ () => undefined,
267
+ );
268
+ }
269
+ }
168
270
  // Server builds keep the real modules.
169
271
  if (options?.ssr) return undefined;
170
272
  // Bare-specifier exact-match stubs (react-dom/server, node:stream, etc.).
@@ -188,6 +290,16 @@ export function decoVitePlugin() {
188
290
  return "export const blocks = {};";
189
291
  }
190
292
 
293
+ // Fast-deploy: stub on the SERVER too. The worker hydrates the decofile
294
+ // from KV at cold start, so shipping it in the bundle would just pin a
295
+ // second, permanently-reachable copy in isolate memory (the site's
296
+ // `import { blocks }` binding keeps the bundled graph alive for the
297
+ // isolate's whole life, even after `setBlocks()` swaps in the KV one).
298
+ // Build only — see `isBuild` above.
299
+ if (stubBlocks && isBuild) {
300
+ return "export const blocks = {};";
301
+ }
302
+
191
303
  // SSR: read .json sibling and emit JSON.parse(...) wrapper.
192
304
  // This avoids the Vite SSR module runner hanging on large dynamic
193
305
  // imports and lets V8 use its fast JSON parser (~2-10x vs object literal).
@@ -202,6 +314,26 @@ export function decoVitePlugin() {
202
314
  // backward-compatible sites that haven't regenerated).
203
315
  }
204
316
 
317
+ // meta.gen.json — the admin JSON Schema bundle, on the SERVER.
318
+ //
319
+ // Vite's JSON plugin would turn it into an object literal, which V8 must
320
+ // run through the full JS parser. Emitting `JSON.parse("...")` instead
321
+ // (same treatment blocks.gen gets, and the same reason) uses V8's fast
322
+ // JSON parser and — the part that matters for memory — keeps the schema
323
+ // as ONE string until something actually calls the parse. Since
324
+ // `createAdminSetup` now only invokes its loader on the first
325
+ // `/live/_meta`, an isolate that never serves the admin never
326
+ // materializes the object graph at all.
327
+ //
328
+ // The client is stubbed in `resolveId` above and never reaches here.
329
+ if (id.startsWith(META_GEN_PREFIX) && id.endsWith(META_GEN_SUFFIX)) {
330
+ const file = id.slice(META_GEN_PREFIX.length, -META_GEN_SUFFIX.length);
331
+ // Virtual id → Vite won't watch the file on its own.
332
+ this.addWatchFile?.(file);
333
+ const raw = readFileSync(file, "utf-8");
334
+ return `export default JSON.parse(${JSON.stringify(raw)});`;
335
+ }
336
+
205
337
  // loaders.gen.ts — the site's invoke registry (`export const siteLoaders`).
206
338
  // It registers every site loader/action behind a dynamic `import()`, so if
207
339
  // it stays in the CLIENT module graph (it's reachable via
@@ -454,6 +586,8 @@ export function decoVitePlugin() {
454
586
  // re-run generate-schema.ts so meta.gen.json stays in sync during dev.
455
587
  // No --out is passed to the generator below, so it writes to its own
456
588
  // default (.deco/meta.gen.json) — this constant must track that default.
589
+ // Both invocations get their args from generateSchemaArgs() so they can't
590
+ // drift apart (see its doc comment).
457
591
  const schemaWatchDirs = ["src"];
458
592
  const schemaOutFile = path.resolve(cwd, ".deco/meta.gen.json");
459
593
 
@@ -477,7 +611,7 @@ export function decoVitePlugin() {
477
611
  cwd,
478
612
  "node_modules/@decocms/blocks-cli/scripts/generate-schema.ts",
479
613
  );
480
- const cmd = `npx tsx ${JSON.stringify(scriptPath)} --site ${schemaSiteName}`;
614
+ const cmd = `npx tsx ${JSON.stringify(scriptPath)} ${generateSchemaArgs(schemaSiteName).join(" ")}`;
481
615
  exec(cmd, { cwd }, (err) => {
482
616
  schemaInFlight = false;
483
617
  if (err) {
@@ -486,7 +620,7 @@ export function decoVitePlugin() {
486
620
  console.log(`[deco] meta.gen.json updated (${Date.now() - start}ms)`);
487
621
  // Invalidate the meta.gen.json module so SSR picks up fresh schema
488
622
  const mod =
489
- server.environments?.ssr?.moduleGraph?.getModuleById(schemaOutFile);
623
+ server.environments?.ssr?.moduleGraph?.getModuleById(metaGenSsrId(schemaOutFile));
490
624
  if (mod) {
491
625
  server.environments.ssr.moduleGraph.invalidateModule(mod);
492
626
  }
@@ -526,7 +660,7 @@ export function decoVitePlugin() {
526
660
  cwd,
527
661
  "node_modules/@decocms/blocks-cli/scripts/generate-schema.ts",
528
662
  );
529
- execFileSync("npx", ["tsx", scriptPath, "--site", schemaSiteName], {
663
+ execFileSync("npx", ["tsx", scriptPath, ...generateSchemaArgs(schemaSiteName)], {
530
664
  cwd,
531
665
  stdio: "inherit",
532
666
  });
@@ -666,13 +800,43 @@ export function decoVitePlugin() {
666
800
  },
667
801
 
668
802
  config(_cfg, { command }) {
803
+ // Gate the fastDeploy SSR stub in `load()` to production builds.
804
+ isBuild = command === "build";
805
+
669
806
  /** @type {import("vite").UserConfig} */
670
807
  const cfg = {};
671
808
 
809
+ // Supply a default TanStack Start entry when the site has none.
810
+ //
811
+ // `#tanstack-start-entry` is a subpath import of
812
+ // @tanstack/start-client-core; with no `src/start.ts` it resolves to a
813
+ // fake entry exporting `startInstance = undefined`, and the site gets no
814
+ // `serverFns.fetch` hook. That hook is what appends the cache segment to
815
+ // `/_serverFn` URLs (see sdk/cdnSegment), so without it CDN caching of
816
+ // SPA navigation can never engage.
817
+ //
818
+ // Aliasing it here means a site gets that from a version bump instead of
819
+ // a per-site PR. Strictly conditional on the file being absent: a site
820
+ // that owns its `src/start.ts` must keep it, since ours would otherwise
821
+ // silently replace whatever else it configures.
822
+ const siteStartEntry = ["ts", "tsx", "js", "jsx"]
823
+ .map((ext) => path.join(process.cwd(), "src", `start.${ext}`))
824
+ .find((f) => existsSync(f));
825
+
826
+ if (!siteStartEntry) {
827
+ cfg.resolve = {
828
+ ...cfg.resolve,
829
+ alias: {
830
+ ...cfg.resolve?.alias,
831
+ "#tanstack-start-entry": "@decocms/tanstack/sdk/startEntry",
832
+ },
833
+ };
834
+ }
835
+
672
836
  // Allow tunnel domains through Vite's host check.
673
- // .deco.studio is the new admin frontend; both real-world Deco sites
674
- // (casaevideo-storefront, baggagio-tanstack) duplicated this list to
675
- // include it — bundling it here removes that boilerplate.
837
+ // .deco.studio is the new admin frontend; multiple real-world Deco sites
838
+ // duplicated this list to include it — bundling it here removes that
839
+ // boilerplate.
676
840
  if (process.env.DECO_SITE_NAME) {
677
841
  cfg.server = {
678
842
  allowedHosts: [".deco.host", ".decocdn.com", ".deco.studio"],
@@ -691,6 +855,14 @@ export function decoVitePlugin() {
691
855
  cfg.define = {
692
856
  ...cfg.define,
693
857
  __DECO_BUILD_HASH__: JSON.stringify(buildHash),
858
+ // Tells the runtime whether this bundle actually ships a decofile.
859
+ // `ensureBlocksHydrated` needs a build-time answer: with no bundled
860
+ // snapshot, its "warn and serve bundled" recovery would serve an EMPTY
861
+ // site with 200s. Inferring it from an empty in-memory map at runtime
862
+ // is not equivalent — draft-preview overrides can make a stubbed bundle
863
+ // look populated, and a legitimately-empty decofile (new site) would
864
+ // look stubbed.
865
+ __DECO_BLOCKS_STUBBED__: JSON.stringify(stubBlocks && command === "build"),
694
866
  };
695
867
 
696
868
  // Only split chunks for production builds — dev uses unbundled ESM.