From fff2760412b364e665f68c25a2790be36531ed26 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 14:58:13 -0300 Subject: [PATCH] feat(web): content backend selection (pure) Adds `selectContentBackend`, the pure choice between the legacy path and the content protocol (GitHub, sandbox or a local `deco serve`). v7 sites stay on the legacy path; v8 is chosen only by `blocksMajor: 8`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sections-editor/content-backend.test.ts | 360 ++++++++++++++++++ .../sections-editor/content-backend.ts | 200 ++++++++++ 2 files changed, 560 insertions(+) create mode 100644 apps/web/src/components/sections-editor/content-backend.test.ts create mode 100644 apps/web/src/components/sections-editor/content-backend.ts diff --git a/apps/web/src/components/sections-editor/content-backend.test.ts b/apps/web/src/components/sections-editor/content-backend.test.ts new file mode 100644 index 0000000000..3b8e14837a --- /dev/null +++ b/apps/web/src/components/sections-editor/content-backend.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, test } from "bun:test"; +import { + blockKeysOfWrite, + isProtocolProject, + isV8Schema, + mergePolledBlocks, + newBlocksEditorEnabled, + selectContentBackend, + servePreviewUrl, +} from "./content-backend"; + +describe("selectContentBackend", () => { + const base = { + hasProject: true, + flagEnabled: true, + hasServeConnection: false, + hasLocalTunnel: false, + runtime: "cms" as const, + githubSite: "v8" as const, + }; + + test("waits for the org flag before the GitHub backend", () => { + expect(selectContentBackend({ ...base, flagEnabled: undefined })).toBe( + "pending", + ); + // The tunnel and sandbox sessions don't need the flag to be known. + expect( + selectContentBackend({ + ...base, + flagEnabled: undefined, + hasLocalTunnel: true, + }), + ).toBe("legacy"); + }); + + test("the flag off keeps the GitHub backend legacy", () => { + expect(selectContentBackend({ ...base, flagEnabled: false })).toBe( + "legacy", + ); + }); + + test("a connected deco serve needs no flag", () => { + for (const flagEnabled of [false, undefined]) { + expect( + selectContentBackend({ + ...base, + flagEnabled, + hasServeConnection: true, + }), + ).toBe("protocol-local"); + } + }); + + test("a connected deco serve wins, over the tunnel and any runtime", () => { + expect( + selectContentBackend({ + ...base, + hasServeConnection: true, + hasLocalTunnel: true, + runtime: "sandbox", + githubSite: "v7", + }), + ).toBe("protocol-local"); + }); + + test("the legacy tunnel and sandbox sessions stay legacy", () => { + expect(selectContentBackend({ ...base, hasLocalTunnel: true })).toBe( + "legacy", + ); + expect(selectContentBackend({ ...base, runtime: "sandbox" })).toBe( + "legacy", + ); + }); + + test("a sandbox session follows its working tree's blocksMajor", () => { + const sandbox = { + ...base, + runtime: "sandbox" as const, + githubSite: "v7" as const, + }; + expect(selectContentBackend({ ...sandbox, sandboxSite: "v8" })).toBe( + "protocol-sandbox", + ); + // Never pending: a v7 sandbox keeps the legacy editor mounted while the + // probe loads; a v7 working tree, or a failed probe: as before. + for (const sandboxSite of ["loading", "v7", "error"] as const) { + expect(selectContentBackend({ ...sandbox, sandboxSite })).toBe("legacy"); + } + }); + + test("a sandbox session is legacy while booting, and with the flag off", () => { + const sandbox = { ...base, runtime: "sandbox" as const }; + // No wait on the flag or the probe: today's UX until v8 is confirmed. + for (const flagEnabled of [true, false, undefined]) { + for (const sandboxSite of [ + "unavailable", + "loading", + "v7", + "error", + undefined, + ] as const) { + expect( + selectContentBackend({ ...sandbox, flagEnabled, sandboxSite }), + ).toBe("legacy"); + } + } + expect( + selectContentBackend({ + ...sandbox, + flagEnabled: false, + sandboxSite: "v8", + }), + ).toBe("legacy"); + expect( + selectContentBackend({ + ...sandbox, + flagEnabled: undefined, + sandboxSite: "v8", + }), + ).toBe("legacy"); + // A connected deco serve and the tunnel still win. + expect( + selectContentBackend({ + ...sandbox, + sandboxSite: "v8", + hasServeConnection: true, + }), + ).toBe("protocol-local"); + expect( + selectContentBackend({ + ...sandbox, + sandboxSite: "v8", + hasLocalTunnel: true, + }), + ).toBe("legacy"); + }); + + test("a cms session follows the committed schema's blocksMajor", () => { + expect(selectContentBackend(base)).toBe("protocol-github"); + expect(selectContentBackend({ ...base, githubSite: "v7" })).toBe("legacy"); + expect(selectContentBackend({ ...base, githubSite: "loading" })).toBe( + "pending", + ); + }); + + test("a failed GitHub probe is v7, never unavailable", () => { + expect(selectContentBackend({ ...base, githubSite: "error" })).toBe( + "legacy", + ); + }); + + test("without a project (SEO, blog forms) it is legacy right away", () => { + for (const flagEnabled of [true, false, undefined]) { + expect( + selectContentBackend({ + ...base, + hasProject: false, + flagEnabled, + githubSite: "loading", + }), + ).toBe("legacy"); + } + }); +}); + +describe("isV8Schema", () => { + test('only "blocksMajor": 8 is v8', () => { + expect(isV8Schema({ major: 1, blocksMajor: 8 })).toBe(true); + }); + + test("a missing field or any other value is v7", () => { + for (const schema of [ + { major: 1 }, + { major: 1, blocksMajor: 7 }, + { major: 1, blocksMajor: "8" }, + { major: 1, blocksMajor: null }, + { major: 1, blocksMajor: 9 }, + null, + undefined, + "8", + 8, + ]) { + expect(isV8Schema(schema)).toBe(false); + } + }); +}); + +describe("isProtocolProject", () => { + test("a protocol endpoint, usable or not", () => { + expect(isProtocolProject({ kind: "unavailable", source: "local" })).toBe( + true, + ); + expect(isProtocolProject({ kind: "legacy" })).toBe(false); + expect(isProtocolProject({ kind: "pending" })).toBe(false); + }); +}); + +describe("mergePolledBlocks", () => { + test("the remote map wins", () => { + expect( + mergePolledBlocks({ a: 1, b: 2 }, { a: 0, c: 3 }, new Set()), + ).toEqual({ a: 1, b: 2 }); + }); + + test("entries still being saved keep their local value", () => { + expect( + mergePolledBlocks( + { a: 1, b: 2 }, + { a: "editing", b: 2, d: "new" }, + new Set(["a", "d"]), + ), + ).toEqual({ a: "editing", b: 2, d: "new" }); + }); + + test("an entry being deleted stays deleted", () => { + expect( + mergePolledBlocks({ a: 1, gone: 2 }, { a: 1 }, new Set(["gone"])), + ).toEqual({ a: 1 }); + }); + + test("with no local copy, the remote map as is", () => { + const remote = { a: 1 }; + expect(mergePolledBlocks(remote, undefined, new Set(["a"]))).toBe(remote); + }); +}); + +describe("blockKeysOfWrite", () => { + test("reads the save, delete and move mutation shapes", () => { + expect(blockKeysOfWrite({ blockKey: "home", data: {} })).toEqual(["home"]); + expect( + blockKeysOfWrite({ writes: { b: {}, c: {} }, deletes: ["a", 1] }), + ).toEqual(["b", "c", "a"]); + expect(blockKeysOfWrite(undefined)).toEqual([]); + expect(blockKeysOfWrite("nope")).toEqual([]); + }); +}); + +describe("servePreviewUrl", () => { + const local = (preview: { url: string } | null) => + ({ + kind: "protocol", + source: "local", + client: {}, + describe: { preview }, + cacheKeySuffix: "", + }) as unknown as Parameters[0]; + + test("loads the app deco serve --preview names, on this machine", () => { + expect(servePreviewUrl(local({ url: "http://localhost:8001" }))).toBe( + "http://localhost:8001", + ); + expect(servePreviewUrl(local({ url: "http://127.0.0.1:3000/en/" }))).toBe( + "http://127.0.0.1:3000/en/", + ); + }); + + test("refuses a preview anywhere else", () => { + for (const url of [ + "https://example.com", + "file:///etc/passwd", + "javascript:alert(1)", + "http://evil.test:80", + ]) { + expect(servePreviewUrl(local({ url }))).toBeNull(); + } + expect(servePreviewUrl(local(null))).toBeNull(); + }); + + test("only a local deco serve has one", () => { + expect(servePreviewUrl({ kind: "legacy" })).toBeNull(); + expect( + servePreviewUrl({ + ...local({ url: "http://localhost:8001" }), + source: "github", + } as Parameters[0]), + ).toBeNull(); + }); +}); + +describe("newBlocksEditorEnabled", () => { + const kinds = ["protocol", "unavailable", "legacy", "pending"] as const; + + test("a v8 site gets the new editor whatever the org flag says", () => { + for (const backend of ["protocol", "unavailable"] as const) { + for (const orgFlag of [true, false, undefined]) { + expect(newBlocksEditorEnabled({ backend, hasOrg: true, orgFlag })).toBe( + true, + ); + } + } + }); + + test("a v7 site follows the org flag, waiting while it loads", () => { + for (const orgFlag of [true, false, undefined]) { + expect( + newBlocksEditorEnabled({ backend: "legacy", hasOrg: true, orgFlag }), + ).toBe(orgFlag); + } + }); + + test("outside a site the org flag alone decides", () => { + for (const orgFlag of [true, false, undefined]) { + expect( + newBlocksEditorEnabled({ backend: null, hasOrg: true, orgFlag }), + ).toBe(orgFlag); + } + }); + + test("no org (/site-editor): v8 is on, and nothing waits on a flag", () => { + for (const orgFlag of [true, false, undefined]) { + expect( + newBlocksEditorEnabled({ backend: "protocol", hasOrg: false, orgFlag }), + ).toBe(true); + expect( + newBlocksEditorEnabled({ + backend: "unavailable", + hasOrg: false, + orgFlag, + }), + ).toBe(true); + // A v7 site can't be reached without an org, but it wouldn't hang. + expect( + newBlocksEditorEnabled({ backend: "legacy", hasOrg: false, orgFlag }), + ).toBe(false); + } + }); + + test("while the version is detected it waits, unless the flag is on", () => { + expect( + newBlocksEditorEnabled({ + backend: "pending", + hasOrg: true, + orgFlag: true, + }), + ).toBe(true); + for (const orgFlag of [false, undefined]) { + expect( + newBlocksEditorEnabled({ backend: "pending", hasOrg: true, orgFlag }), + ).toBeUndefined(); + } + expect( + newBlocksEditorEnabled({ + backend: "pending", + hasOrg: false, + orgFlag: undefined, + }), + ).toBeUndefined(); + }); + + test("never the old editor for a site that may be v8", () => { + for (const backend of kinds) { + const decided = newBlocksEditorEnabled({ + backend, + hasOrg: false, + orgFlag: undefined, + }); + if (backend !== "legacy") expect(decided).not.toBe(false); + } + }); +}); diff --git a/apps/web/src/components/sections-editor/content-backend.ts b/apps/web/src/components/sections-editor/content-backend.ts new file mode 100644 index 0000000000..d26844e51e --- /dev/null +++ b/apps/web/src/components/sections-editor/content-backend.ts @@ -0,0 +1,200 @@ +/** + * Where the site editor reads and writes a project's content. + * + * - `legacy`: the running site (`/live/_meta`, `/.decofile`), the sandbox + * working tree, or the Fast Preview decofile API — everything that existed + * before next-major Blocks. + * - `protocol`: the Blocks content protocol, which needs only the committed + * schema and `.deco/blocks` and never runs the site's code. Served by a + * `deco serve` on the editor's machine (`local`), by Studio's GitHub backend + * (`github`), or by a sandbox's daemon over its working tree (`sandbox`). + * + * Pure: the selection and the poll merge are unit-tested without mocks. + */ + +import type { ContentClient, DescribeResult } from "@decocms/blocks/protocol"; +import { isLoopbackEndpoint, type ServeProblem } from "./deco-serve-connection"; + +export type ContentSource = "github" | "local" | "sandbox"; + +export interface ProtocolBackend { + kind: "protocol"; + source: ContentSource; + client: ContentClient; + describe: DescribeResult; + /** Distinguishes cache entries of different endpoints for one project. */ + cacheKeySuffix: string; + /** Whether the endpoint has a schema (`deco schema` was run). */ + hasSchema?: boolean; +} + +export type ContentBackend = + /** Not decided yet: the flag or the probe is still loading. */ + | { kind: "pending" } + | { kind: "legacy" } + | ProtocolBackend + /** A protocol endpoint that can't be reached right now. */ + | { + kind: "unavailable"; + source: ContentSource; + /** Why a `deco serve` can't be used (local only). */ + problem?: ServeProblem; + }; + +/** + * A content-protocol project, usable right now or not. The protocol never + * runs site code: no in-place or gallery renders, no invoke-backed pickers, + * no Run, no app install. + */ +export function isProtocolProject(backend: ContentBackend): boolean { + return backend.kind === "protocol" || backend.kind === "unavailable"; +} + +/** + * Whether the site editor shows the redesigned blocks editor. A v8 site (any + * content-protocol project) always does; a v7 site follows the org's + * `new_blocks_editor` flag. `undefined` while that can't be told yet, so the + * editor waits instead of showing one editor and swapping to the other. + * + * `backend` is `null` outside a site (org settings, say): the flag alone. + * `orgFlag` is `undefined` while the org settings load. With no org (the + * account-less `/site-editor`) there is no flag to read: nothing opted in. + */ +export function newBlocksEditorEnabled(input: { + backend: ContentBackend["kind"] | null; + hasOrg: boolean; + orgFlag: boolean | undefined; +}): boolean | undefined { + const orgFlag = input.hasOrg ? input.orgFlag : false; + const { backend } = input; + if (backend === "protocol" || backend === "unavailable") return true; + // Undecided: either generation gets the new editor when the flag is on. + if (backend === "pending") return orgFlag === true ? true : undefined; + return orgFlag; +} + +/** + * The app a connected `deco serve` previews (`describe.preview`, which + * `deco serve --preview` sets): its URL when it is on this machine, else + * `null`. Anywhere else would put an arbitrary page in the editor's frame. + */ +export function servePreviewUrl(backend: ContentBackend): string | null { + if (backend.kind !== "protocol" || backend.source !== "local") return null; + const url = backend.describe.preview?.url; + return url && isLoopbackEndpoint(url) ? url : null; +} + +/** + * The Blocks major a v8 schema declares: `deco schema` writes a top-level + * `"blocksMajor": 8` into `.deco/schema.gen.json` (`BLOCKS_MAJOR` in + * `@decocms/blocks/protocol`, which `deco check` requires). Kept here rather + * than imported because the pinned `@decocms/blocks` predates the constant. + */ +const V8_BLOCKS_MAJOR = 8; + +/** + * Whether a committed schema is a Blocks v8 one: only `blocksMajor === 8` + * says so. The file name doesn't (v7 sites commit `meta.gen.json`, and either + * name may lack the field); a missing field, any other value, or anything + * that isn't a schema object is v7. + */ +export function isV8Schema(schema: unknown): boolean { + return ( + typeof schema === "object" && + schema !== null && + (schema as { blocksMajor?: unknown }).blocksMajor === V8_BLOCKS_MAJOR + ); +} + +export type BackendDecision = + | "pending" + | "legacy" + | "protocol-local" + | "protocol-github" + | "protocol-sandbox"; + +/** + * Which backend a project's editor uses. Outside a project (forms with no + * project id, such as SEO and the blog registry) there is no site to probe: + * legacy. A connected `deco serve` wins, for every org: it exists only once + * someone pasted its link into the "Local" draft option or opened the link it + * printed, and only a Blocks v8 `deco serve` answers it. Then the legacy + * Local tunnel. Behind the org flag, a Fast Preview (`cms`) session uses the + * GitHub backend only when the branch's committed schema says + * `"blocksMajor": 8` ({@link isV8Schema}). Everything else is v7 and legacy, + * as before next-major Blocks — including a failed probe, which is retried in + * the background (a site already known to be v8 keeps that answer). A sandbox + * session, behind the same flag, uses its daemon's content protocol (the + * working tree, saved like a `deco serve`'s) once the working tree is there + * and its schema says `"blocksMajor": 8`; until then, and for v7, legacy. + */ +export function selectContentBackend(input: { + /** Whether there is a project to probe (a virtual MCP id). */ + hasProject: boolean; + /** The org flag (GitHub backend only); `undefined` while it loads. */ + flagEnabled: boolean | undefined; + hasServeConnection: boolean; + hasLocalTunnel: boolean; + runtime: "cms" | "sandbox"; + /** The GitHub probe: is the branch's committed schema a v8 one? */ + githubSite: "v8" | "v7" | "loading" | "error"; + /** + * The sandbox's daemon probe: is the working tree's schema a v8 one? + * `unavailable` until the working tree is there (the sandbox is booting). + */ + sandboxSite?: "v8" | "v7" | "loading" | "error" | "unavailable"; +}): BackendDecision { + if (!input.hasProject) return "legacy"; + if (input.hasServeConnection) return "protocol-local"; + if (input.hasLocalTunnel) return "legacy"; + if (input.runtime === "sandbox") { + // Legacy until a v8 site is confirmed: never `pending`, so a v7 sandbox + // keeps today's editor with no wait on the flag or the daemon probe. + // OPEN: a v8 site shows legacy until the working tree lands and the + // probe answers, then switches (smallest option; no new wait state). + return input.flagEnabled === true && input.sandboxSite === "v8" + ? "protocol-sandbox" + : "legacy"; + } + if (input.flagEnabled === undefined) return "pending"; + if (!input.flagEnabled) return "legacy"; + if (input.githubSite === "loading") return "pending"; + return input.githubSite === "v8" ? "protocol-github" : "legacy"; +} + +/** + * Applies a polled block map over the local copy: the remote map wins, except + * for entries the editor is still saving, which keep their local value (or + * stay deleted) until their own write lands. + */ +export function mergePolledBlocks( + remote: Record, + local: Record | undefined, + saving: ReadonlySet, +): Record { + if (!local || saving.size === 0) return remote; + const next = { ...remote }; + for (const key of saving) { + if (Object.hasOwn(local, key)) next[key] = local[key]; + else delete next[key]; + } + return next; +} + +/** + * The block names an in-flight decofile write touches, from the variables of + * the write mutations (`{ blockKey }` or `{ writes, deletes }`). + */ +export function blockKeysOfWrite(variables: unknown): string[] { + if (!variables || typeof variables !== "object") return []; + const v = variables as Record; + const keys: string[] = []; + if (typeof v.blockKey === "string") keys.push(v.blockKey); + if (v.writes && typeof v.writes === "object") { + keys.push(...Object.keys(v.writes)); + } + if (Array.isArray(v.deletes)) { + keys.push(...v.deletes.filter((k): k is string => typeof k === "string")); + } + return keys; +}