@decocms/tanstack 7.72.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.72.0",
3
+ "version": "8.1.0-next.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -9,6 +9,10 @@
9
9
  "directory": "packages/tanstack"
10
10
  },
11
11
  "main": "./src/index.ts",
12
+ "sideEffects": [
13
+ "./src/vite/**",
14
+ "./src/daemon/**"
15
+ ],
12
16
  "exports": {
13
17
  ".": "./src/index.ts",
14
18
  "./vite": "./src/vite/plugin.js",
@@ -27,9 +31,9 @@
27
31
  "lint:unused": "knip"
28
32
  },
29
33
  "dependencies": {
30
- "@decocms/blocks": "7.72.0",
31
- "@decocms/blocks-admin": "7.72.0",
32
- "@decocms/blocks-cli": "7.72.0",
34
+ "@decocms/blocks": "8.1.0-next.0",
35
+ "@decocms/blocks-admin": "8.1.0-next.0",
36
+ "@decocms/blocks-cli": "8.1.0-next.0",
33
37
  "@deco-cx/warp-node": "^0.3.16",
34
38
  "fast-json-patch": "^3.1.0",
35
39
  "ws": "^8.18.0"
@@ -0,0 +1,221 @@
1
+ // @vitest-environment node
2
+ /**
3
+ * Conformance: the framework-binding half of telemetry.mdx ("What's sent"),
4
+ * telemetry-internals.mdx ("Sending in the background") and caching.mdx
5
+ * ("Upstream data"), for `@decocms/tanstack` on Cloudflare Workers.
6
+ *
7
+ * A site on Workers creates its CMS with `createCMS({ telemetry })` from
8
+ * `@decocms/blocks` and serves through the binding's worker entry. The docs
9
+ * say the binding then adds inbound request and edge-cache metrics to that
10
+ * CMS's telemetry, sends batches inside `ctx.waitUntil`, and caches upstream
11
+ * responses (made through `createInstrumentedFetch`) with the Cache API,
12
+ * except while a draft is being rendered.
13
+ */
14
+ import { gunzipSync } from "node:zlib";
15
+ import { createCMS, DRAFT_COOKIE, resetForTests } from "@decocms/blocks";
16
+ import { setBlocks } from "@decocms/blocks/cms";
17
+ import { createInstrumentedFetch } from "@decocms/blocks/fetch";
18
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
19
+ import { createDecoWorkerEntry } from "../sdk/workerEntry";
20
+
21
+ const ENDPOINT = "https://otel.example.com";
22
+
23
+ let clock = 0;
24
+ let waits: Promise<unknown>[] = [];
25
+ const ctx = {
26
+ waitUntil: (promise: Promise<unknown>) => void waits.push(promise),
27
+ passThroughOnException: () => {},
28
+ };
29
+
30
+ interface Sent {
31
+ url: string;
32
+ text: string;
33
+ body: any;
34
+ }
35
+
36
+ let sent: Sent[] = [];
37
+ let upstreamCalls: string[] = [];
38
+
39
+ beforeEach(() => {
40
+ resetForTests();
41
+ // The only worker entry @decocms/tanstack has is v7's, which needs v7's block
42
+ // registry loaded even for an app that only uses createCMS.
43
+ setBlocks({});
44
+ clock = 0;
45
+ waits = [];
46
+ sent = [];
47
+ upstreamCalls = [];
48
+ const now = Date.now.bind(Date);
49
+ vi.spyOn(Date, "now").mockImplementation(() => now() + clock);
50
+ vi.stubEnv("OTEL_EXPORTER_OTLP_ENDPOINT", "");
51
+ vi.stubGlobal(
52
+ "fetch",
53
+ vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
54
+ const url = String(input instanceof Request ? input.url : input);
55
+ if (url.startsWith(ENDPOINT)) {
56
+ const raw = Buffer.from(init?.body as Uint8Array);
57
+ const gz = new Headers(init?.headers).get("content-encoding") === "gzip";
58
+ const text = gz ? gunzipSync(raw).toString() : String(raw);
59
+ sent.push({ url, text, body: JSON.parse(text) });
60
+ return new Response(null, { status: 200 });
61
+ }
62
+ upstreamCalls.push(url);
63
+ return new Response(JSON.stringify({ hits: [] }), {
64
+ status: 200,
65
+ headers: { "content-type": "application/json", "cache-control": "max-age=60" },
66
+ });
67
+ }),
68
+ );
69
+ });
70
+
71
+ afterEach(() => {
72
+ delete (globalThis as Record<symbol, unknown>)[Symbol.for("decocms.blocks.background")];
73
+ vi.unstubAllGlobals();
74
+ vi.unstubAllEnvs();
75
+ vi.restoreAllMocks();
76
+ resetForTests();
77
+ });
78
+
79
+ /** A Cache API stand-in (`caches.default`) that remembers what was put. */
80
+ function fakeCaches() {
81
+ const store = new Map<string, Response>();
82
+ const puts: string[] = [];
83
+ const cache = {
84
+ match: vi.fn(async (request: Request | string) => {
85
+ const key = typeof request === "string" ? request : request.url;
86
+ return store.get(key)?.clone();
87
+ }),
88
+ put: vi.fn(async (request: Request | string, response: Response) => {
89
+ const key = typeof request === "string" ? request : request.url;
90
+ puts.push(key);
91
+ store.set(key, response.clone());
92
+ }),
93
+ delete: vi.fn(async () => true),
94
+ };
95
+ vi.stubGlobal("caches", { default: cache, open: async () => cache });
96
+ return { cache, puts };
97
+ }
98
+
99
+ /** Lets every queued background task run, one batch window (10 s) after the last. */
100
+ async function drain(): Promise<void> {
101
+ for (let i = 0; i < 5; i++) {
102
+ clock += 10_000;
103
+ const pending = waits.splice(0);
104
+ if (pending.length === 0) break;
105
+ await Promise.all(pending);
106
+ }
107
+ }
108
+
109
+ function metrics() {
110
+ return sent
111
+ .filter((s) => s.url.endsWith("/v1/metrics"))
112
+ .flatMap((s) => s.body.resourceMetrics[0].scopeMetrics[0].metrics);
113
+ }
114
+
115
+ function attrs(list: { key: string; value: Record<string, unknown> }[] = []) {
116
+ return Object.fromEntries(list.map(({ key, value }) => [key, Object.values(value)[0]]));
117
+ }
118
+
119
+ const search = createInstrumentedFetch({ provider: "acme-search" });
120
+
121
+ /** The app's server entry: a product page that calls an upstream API through the instrumented fetch. */
122
+ const serverEntry = {
123
+ fetch: async (request: Request) => {
124
+ const url = new URL(request.url);
125
+ if (url.pathname.startsWith("/products/")) {
126
+ await search("https://search.example/v1/search?q=shirt", { operation: "search" });
127
+ return new Response("<html>product</html>", {
128
+ status: 200,
129
+ headers: { "content-type": "text/html" },
130
+ });
131
+ }
132
+ return new Response("not found", { status: 404 });
133
+ },
134
+ };
135
+
136
+ function cms() {
137
+ return createCMS({
138
+ blocks: {},
139
+ content: { revision: "rev-1", root: "tanstack-conformance/.deco", blocks: {} },
140
+ telemetry: { endpoint: ENDPOINT },
141
+ });
142
+ }
143
+
144
+ async function serve(worker: ReturnType<typeof createDecoWorkerEntry>, path: string, headers = {}) {
145
+ const response = await worker.fetch(
146
+ new Request(`https://shop.example${path}`, { headers }),
147
+ {},
148
+ ctx,
149
+ );
150
+ await response.text();
151
+ return response;
152
+ }
153
+
154
+ describe("sending in the background (telemetry-internals.mdx)", () => {
155
+ it("tin-06: on Workers, batches go out after the response, inside ctx.waitUntil", async () => {
156
+ cms();
157
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
158
+ await serve(worker, "/products/abc-123");
159
+ expect(sent).toEqual([]); // nothing went out in front of the response
160
+ clock += 10_000;
161
+ await serve(worker, "/products/abc-123");
162
+ expect(waits.length).toBeGreaterThan(0);
163
+ await drain();
164
+ expect(metrics().map((m) => m.name)).toContain("http.client.request.duration");
165
+ });
166
+ });
167
+
168
+ describe("what the binding adds (telemetry.mdx › What's sent)", () => {
169
+ it("tel-09: the binding adds no measurements of its own (inbound requests and the page cache are the framework's)", async () => {
170
+ cms();
171
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
172
+ await serve(worker, "/products/abc-123?x=1");
173
+ clock += 10_000;
174
+ await serve(worker, "/products/abc-123?x=1");
175
+ await drain();
176
+ const names = new Set(metrics().map((m) => m.name));
177
+ expect(names).toContain("http.client.request.duration");
178
+ expect(names).not.toContain("http.server.request.duration");
179
+ expect(names).not.toContain("deco.cache.requests");
180
+ expect([...names].every((name) => name === "http.client.request.duration")).toBe(true);
181
+ });
182
+ });
183
+
184
+ describe("upstream data (caching.mdx)", () => {
185
+ it("cache-03/tel-15/cache-05: upstream GETs are cached with the Cache API; a hit is labeled cached=true", async () => {
186
+ const { cache } = fakeCaches();
187
+ cms();
188
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
189
+ // Two different pages (so the page cache can't answer) calling the same upstream URL.
190
+ await serve(worker, "/products/a");
191
+ await serve(worker, "/products/b");
192
+ expect(upstreamCalls).toHaveLength(1);
193
+ expect(cache.put).toHaveBeenCalled();
194
+ clock += 10_000;
195
+ await serve(worker, "/products/other");
196
+ await drain();
197
+ const client = metrics().find((m) => m.name === "http.client.request.duration");
198
+ const labels = client.histogram.dataPoints.map((p: any) => attrs(p.attributes).cached);
199
+ expect(labels).toContain(true);
200
+ expect(labels).toContain(false);
201
+ });
202
+
203
+ it("cache-06: while a draft is rendered, upstream calls bypass the cache", async () => {
204
+ const { cache, puts } = fakeCaches();
205
+ cms();
206
+ const worker = createDecoWorkerEntry(serverEntry, { observability: false });
207
+ // Without a draft, the second call is a cache hit (the precondition).
208
+ await serve(worker, "/products/a");
209
+ await serve(worker, "/products/b");
210
+ expect(upstreamCalls).toHaveLength(1);
211
+ const putsBefore = puts.length;
212
+ cache.match.mockClear();
213
+ const draft = {
214
+ cookie: `${DRAFT_COOKIE}=${encodeURIComponent("content.example/drafts/1@v1")}`,
215
+ };
216
+ await serve(worker, "/products/c", draft);
217
+ await serve(worker, "/products/d", draft);
218
+ expect(upstreamCalls).toHaveLength(3);
219
+ expect(puts.length).toBe(putsBefore);
220
+ });
221
+ });
package/src/index.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  export {
2
+ DecoPageRenderer,
3
+ DecoRootLayout,
4
+ DraftPreviewIndicator,
5
+ NavigationProgress,
6
+ PreviewProviders,
7
+ SectionList,
8
+ SectionRenderer,
9
+ StableOutlet,
10
+ } from "./hooks";
11
+ export {
12
+ CmsPage,
2
13
  cmsHomeRouteConfig,
3
14
  cmsRouteConfig,
4
- CmsPage,
5
15
  decoInvokeRouteConfig,
6
16
  decoMetaRouteConfig,
7
17
  decoRenderRouteConfig,
@@ -11,33 +21,23 @@ export {
11
21
  NotFoundPage,
12
22
  withSiteGlobals,
13
23
  } from "./routes";
14
- export {
15
- DecoPageRenderer,
16
- DecoRootLayout,
17
- DraftPreviewIndicator,
18
- NavigationProgress,
19
- PreviewProviders,
20
- SectionList,
21
- SectionRenderer,
22
- StableOutlet,
23
- } from "./hooks";
24
- export { createDecoWorkerEntry } from "./sdk/workerEntry";
25
- export { setupTanstackFastDeploy } from "./setupFastDeploy";
24
+ export type { CreateDecoRouterOptions } from "./sdk/router";
26
25
  export {
27
26
  createDecoRouter,
28
27
  decoParseSearch,
29
28
  decoStringifySearch,
30
29
  } from "./sdk/router";
31
- export type { CreateDecoRouterOptions } from "./sdk/router";
32
- export {
33
- buildSpeculationRules,
34
- DEFAULT_EXCLUDED_HREF_MATCHES,
35
- } from "./sdk/speculationRules";
36
30
  export type {
37
31
  SpeculationAction,
38
32
  SpeculationEagerness,
39
33
  SpeculationRulesConfig,
40
34
  } from "./sdk/speculationRules";
35
+ export {
36
+ buildSpeculationRules,
37
+ DEFAULT_EXCLUDED_HREF_MATCHES,
38
+ } from "./sdk/speculationRules";
39
+ export { createDecoWorkerEntry } from "./sdk/workerEntry";
40
+ export { setupTanstackFastDeploy } from "./setupFastDeploy";
41
41
  // createInvokeFn is intentionally NOT re-exported from this root barrel.
42
42
  // Its body contains a `createServerFn(...).handler(...)` call that is not a
43
43
  // top-level variable declarator (it's returned from a factory function) --
@@ -57,3 +57,5 @@ export type {
57
57
  // bundle; importing it from here would drag `createDecoWorkerEntry` (and the
58
58
  // whole server graph behind it) into that bundle. Import it from the dedicated
59
59
  // "@decocms/tanstack/sdk/serverFnFetch" subpath instead.
60
+ // Next major (v8): the binding's documented surface. See /next/api-reference#loaders.
61
+ export { kvLoader } from "./v8/kvLoader";
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Workers run no timers between requests: core background work (release
3
+ * checks, telemetry batches) runs after the response, inside ctx.waitUntil
4
+ * (hosted-publishing.mdx, hosted-releases-internals.mdx, telemetry-internals.mdx).
5
+ */
6
+ import { createCMS, type Loader, resetForTests } from "@decocms/blocks";
7
+ import { setBlocks } from "@decocms/blocks/cms";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { createDecoWorkerEntry } from "./workerEntry";
10
+
11
+ const HOOK = Symbol.for("decocms.blocks.background");
12
+
13
+ beforeEach(() => {
14
+ resetForTests();
15
+ setBlocks({}); // the v7 path this entry still runs reads redirects from it
16
+ });
17
+ afterEach(() => {
18
+ delete (globalThis as Record<symbol, unknown>)[HOOK];
19
+ resetForTests();
20
+ });
21
+
22
+ function context() {
23
+ const waited: Promise<unknown>[] = [];
24
+ return {
25
+ waited,
26
+ ctx: { waitUntil: (p: Promise<unknown>) => void waited.push(p), passThroughOnException() {} },
27
+ };
28
+ }
29
+
30
+ describe("createDecoWorkerEntry: background work", () => {
31
+ it("runs a due release check after the response, inside ctx.waitUntil", async () => {
32
+ const update = vi.fn(async () => ({ updated: false }));
33
+ const loader: Loader = {
34
+ load: async () => ({ revision: "r1", blocks: {} }),
35
+ update,
36
+ };
37
+ const cms = createCMS({ blocks: {}, content: loader });
38
+ let checkedBeforeResponse = true;
39
+ const worker = createDecoWorkerEntry(
40
+ {
41
+ fetch: async () => {
42
+ cms.forRelease();
43
+ await Promise.resolve();
44
+ checkedBeforeResponse = update.mock.calls.length > 0;
45
+ return new Response("ok");
46
+ },
47
+ },
48
+ { observability: false },
49
+ );
50
+ const { waited, ctx } = context();
51
+ const response = await worker.fetch(new Request("https://example.com/"), {}, ctx);
52
+ expect(await response.text()).toBe("ok");
53
+ expect(checkedBeforeResponse).toBe(false);
54
+ expect(waited).toHaveLength(1);
55
+ await Promise.all(waited);
56
+ expect(update).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it("calls waitUntil only when there's something queued", async () => {
60
+ const worker = createDecoWorkerEntry(
61
+ { fetch: async () => new Response("ok") },
62
+ { observability: false },
63
+ );
64
+ const { waited, ctx } = context();
65
+ await worker.fetch(new Request("https://example.com/"), {}, ctx);
66
+ expect(waited).toHaveLength(0);
67
+ });
68
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Runs `@decocms/blocks` background work (release checks, telemetry batches)
3
+ * after each response, inside `ctx.waitUntil`. Workers run no timers between
4
+ * requests, so the core's timer fallback would never fire or would be bound
5
+ * to a finished request. See /next/hosted-releases-internals and
6
+ * /next/telemetry-internals#sending-in-the-background.
7
+ *
8
+ * The hook is installed once per isolate under the `Symbol.for` key the core
9
+ * looks for; its queue lives on the hook itself, so two copies of this module
10
+ * share one queue.
11
+ */
12
+
13
+ const HOOK = Symbol.for("decocms.blocks.background");
14
+
15
+ type Task = () => Promise<void>;
16
+ type Hook = ((task: Task) => void) & { queue: Task[] };
17
+
18
+ function hook(): Hook {
19
+ const g = globalThis as unknown as Record<symbol, Hook | undefined>;
20
+ const existing = g[HOOK];
21
+ if (typeof existing === "function" && Array.isArray(existing.queue)) return existing;
22
+ const queue: Task[] = [];
23
+ const installed = Object.assign((task: Task) => void queue.push(task), { queue });
24
+ g[HOOK] = installed;
25
+ return installed;
26
+ }
27
+
28
+ /** Installs the hook (idempotent): from now on, core background work queues here. */
29
+ export function installBackgroundHook(): void {
30
+ hook();
31
+ }
32
+
33
+ /** Runs everything queued so far inside `ctx.waitUntil`. */
34
+ export function runBackgroundTasks(ctx: { waitUntil(promise: Promise<unknown>): void }): void {
35
+ const tasks = hook().queue.splice(0);
36
+ if (tasks.length === 0) return;
37
+ ctx.waitUntil(Promise.all(tasks.map((task) => task().catch(() => {}))));
38
+ }
@@ -25,6 +25,7 @@
25
25
  * ```
26
26
  */
27
27
 
28
+ import { draftPointer } from "@decocms/blocks";
28
29
  import {
29
30
  getRevision,
30
31
  getSectionOptions,
@@ -92,6 +93,8 @@ import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
92
93
  import { reconfigureAppsOnce } from "@decocms/blocks-admin/apps/autoconfig";
93
94
  import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
94
95
  import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
96
+ import { installUpstreamCache } from "../v8/binding";
97
+ import { installBackgroundHook, runBackgroundTasks } from "./backgroundTasks";
95
98
  import { CSEG_BAG_KEY, CSEG_PARAM, segmentToken } from "./cdnSegment";
96
99
  import {
97
100
  applyDraftCookieAndHeaders,
@@ -1287,6 +1290,8 @@ export function createDecoWorkerEntry(
1287
1290
  // `?__draft=` param OR the navigation cookie (SPA nav after entry), gated
1288
1291
  // on an allowed preview host — see requestCarriesDraft.
1289
1292
  if (requestCarriesDraft(request, url)) return false;
1293
+ // A next-major draft (`?__draft=` or the `deco-draft` cookie): same rule.
1294
+ if (draftPointer(request) !== null) return false;
1290
1295
  if (url.searchParams.has("__deco_preview")) return false;
1291
1296
  if (url.searchParams.has("pathTemplate")) return false;
1292
1297
  // Forced matcher results must never be served from (or stored in) the
@@ -2169,6 +2174,21 @@ export function createDecoWorkerEntry(
2169
2174
  // `DECO_OTEL_*_ENDPOINT` env vars are configured, telemetry flows
2170
2175
  // without any change to the site's worker-entry. When the env vars are
2171
2176
  // absent the wrap is a no-op (no exporters created, no flush calls).
2177
+ // `@decocms/blocks` background work (release checks, telemetry batches)
2178
+ // queued during a request runs after its response, inside ctx.waitUntil.
2179
+ installBackgroundHook();
2180
+ // Next major: upstream GETs made with createInstrumentedFetch are cached
2181
+ // with the Cache API (see ../v8/binding.ts).
2182
+ installUpstreamCache();
2183
+ const respond = handler.fetch;
2184
+ handler.fetch = async (request, env, ctx) => {
2185
+ try {
2186
+ return await respond(request, env, ctx);
2187
+ } finally {
2188
+ runBackgroundTasks(ctx);
2189
+ }
2190
+ };
2191
+
2172
2192
  return observabilityOpt === false
2173
2193
  ? handler
2174
2194
  : instrumentWorker(handler, (observabilityOpt as OtelOptions | undefined) ?? {});
@@ -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
+ }