@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.
- package/package.json +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- 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
|
+
}
|
package/src/vite/plugin.js
CHANGED
|
@@ -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
|
-
/**
|
|
158
|
-
|
|
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)}
|
|
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,
|
|
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;
|
|
674
|
-
//
|
|
675
|
-
//
|
|
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.
|