Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions apps/api/src/api/routes/sandbox-proxy.content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it } from "bun:test";
import { Hono, type Context } from "hono";
import { proxyContentAsset, proxyContentRpc } from "./sandbox-proxy";

type Handler = typeof proxyContentRpc;
type VmContext = Parameters<Handler>[0];

interface Sent {
claimName: string;
path: string;
method: string;
contentType: string | null;
body: Uint8Array;
}

/**
* The two content-protocol routes behind a claim like `resolveVmClaim` sets,
* with a runner that records what reached it.
*/
function app(runtime: "sandbox" | "cms", answer: Response) {
const sent: Sent[] = [];
const runner = {
proxyDaemonRequest: async (
claimName: string,
path: string,
init: { method: string; headers: Headers; body: BodyInit | null },
) => {
sent.push({
claimName,
path,
method: init.method,
contentType: init.headers.get("content-type"),
body: new Uint8Array(await new Response(init.body).arrayBuffer()),
});
return answer;
},
adoptLiveClaim: async () => false,
};
const hono = new Hono();
hono.use("/:virtualMcpId/:branch/*", async (c, next) => {
(c as unknown as Context).set("vmClaim", {
claimName: "claim-a",
callerUserId: "u1",
runner: runtime === "cms" ? null : runner,
virtualMcpId: c.req.param("virtualMcpId"),
branch: c.req.param("branch"),
userId: "u1",
projectRef: "p1",
virtualMcpMetadata: null,
connectionIds: [],
runtime,
});
await next();
});
const route = (h: Handler) => (c: Context) => h(c as unknown as VmContext);
hono.post("/:virtualMcpId/:branch/rpc", route(proxyContentRpc));
hono.put("/:virtualMcpId/:branch/assets/:name", route(proxyContentAsset));
return { hono, sent };
}

const rpcAnswer = () =>
new Response('{"jsonrpc":"2.0","id":1,"result":{}}', {
headers: { "content-type": "application/json; charset=utf-8" },
});

describe("content protocol proxy", () => {
it("forwards an rpc request's JSON body to the daemon's /_sandbox/rpc", async () => {
const { hono, sent } = app("sandbox", rpcAnswer());
const body = '{"jsonrpc":"2.0","id":1,"method":"describe"}';
const res = await hono.request("/vm1/main/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body,
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ jsonrpc: "2.0", id: 1, result: {} });
expect(res.headers.get("cache-control")).toContain("no-store");
expect(sent).toHaveLength(1);
expect(sent[0]!.path).toBe("/_sandbox/rpc");
expect(sent[0]!.method).toBe("POST");
expect(sent[0]!.contentType).toBe("application/json");
expect(new TextDecoder().decode(sent[0]!.body)).toBe(body);
});

it("passes the daemon's protocol errors through unchanged", async () => {
const { hono } = app(
"sandbox",
new Response(
'{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"use POST"}}',
{ status: 405, headers: { "content-type": "application/json" } },
),
);
const res = await hono.request("/vm1/main/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
expect(res.status).toBe(405);
expect(((await res.json()) as { error: { code: number } }).error.code).toBe(
-32600,
);
});

it("forwards an asset's bytes and type to /_sandbox/assets/<name>", async () => {
const { hono, sent } = app(
"sandbox",
new Response('{"path":"/assets/logo%20a.png"}', {
status: 201,
headers: { "content-type": "application/json" },
}),
);
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff]);
const res = await hono.request(
`/vm1/main/assets/${encodeURIComponent("logo a.png")}`,
{
method: "PUT",
headers: { "content-type": "image/png" },
body: bytes,
},
);
expect(res.status).toBe(201);
expect(await res.json()).toEqual({ path: "/assets/logo%20a.png" });
expect(sent[0]!.path).toBe("/_sandbox/assets/logo%20a.png");
expect(sent[0]!.method).toBe("PUT");
expect(sent[0]!.contentType).toBe("image/png");
expect([...sent[0]!.body]).toEqual([...bytes]);
});

it("answers 404 for a sandbox-less session, with no daemon call", async () => {
const { hono, sent } = app("cms", rpcAnswer());
const res = await hono.request("/vm1/main/rpc", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
expect(res.status).toBe(404);
expect(sent).toHaveLength(0);
});
});
70 changes: 68 additions & 2 deletions apps/api/src/api/routes/sandbox-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ function assertSandboxBranchParam(branch: string): void {
}

const JUDGE_REVIEW_MAX_BODY_BYTES = 512 * 1024;
/**
* The content protocol's own caps (`@decocms/blocks/protocol` limits): 8 MiB
* per request and 25 MiB per asset (`describe.assets.maxBytes`), plus slack so
* the daemon, not this proxy, answers a body right at the limit.
*/
const CONTENT_RPC_MAX_BODY_BYTES = 8 * 1024 * 1024 + 64 * 1024;
const CONTENT_ASSET_MAX_BODY_BYTES = 25 * 1024 * 1024 + 64 * 1024;
const PREVIEW_INVOKE_MAX_BODY_BYTES = 64 * 1024;

/**
Expand Down Expand Up @@ -512,6 +519,12 @@ async function proxyDaemon(
forwardJsonBody?: boolean;
/** When set, sent instead of reading the request body. */
jsonBody?: string;
/**
* A binary body (an asset upload), sent with `contentType`. Buffered, not
* streamed, so the runner can resend it when it retries a 401.
*/
rawBody?: ArrayBuffer;
contentType?: string;
signal?: AbortSignal;
/** Map 404 to 410 (sandbox needs re-provision). */
map404to410?: boolean;
Expand Down Expand Up @@ -539,10 +552,13 @@ async function proxyDaemon(

const { claimName, userId, projectRef } = c.get("vmClaim");
const method = opts?.method ?? "POST";
let body: string | null = null;
let body: string | ArrayBuffer | null = null;
const headers = new Headers();

if (opts?.jsonBody !== undefined) {
if (opts?.rawBody !== undefined) {
body = opts.rawBody;
headers.set("content-type", opts.contentType ?? "application/octet-stream");
} else if (opts?.jsonBody !== undefined) {
body = opts.jsonBody;
headers.set("content-type", "application/json");
} else if (opts?.forwardJsonBody) {
Expand Down Expand Up @@ -653,6 +669,25 @@ async function proxyDaemon(
}
}

/** `POST …/rpc`: one content-protocol request to the daemon's `/_sandbox/rpc`. */
export function proxyContentRpc(c: Context<VmEnv>) {
return proxyDaemon(c, "/_sandbox/rpc", {
forwardJsonBody: true,
signal: AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(30_000)]),
});
}

/** `PUT …/assets/:name`: an asset upload to the daemon's `/_sandbox/assets/`. */
export async function proxyContentAsset(c: Context<VmEnv>) {
const name = c.req.param("name") ?? "";
return proxyDaemon(c, `/_sandbox/assets/${encodeURIComponent(name)}`, {
method: "PUT",
rawBody: await c.req.arrayBuffer(),
contentType: c.req.header("content-type"),
signal: AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(60_000)]),
});
}

/**
* Set `repoDir` to null in a daemon config JSON payload. Returns the input
* unchanged if it isn't a JSON object with a `repoDir` key, so a non-JSON or
Expand Down Expand Up @@ -805,6 +840,37 @@ export const createSandboxRoutes = () => {
return proxyDaemon(c, `/_sandbox/exec/${encodeURIComponent(script)}/kill`);
});

// -- Content protocol (Blocks v8) -----------------------------------------
// The daemon serves the working tree's `.deco/blocks` over the content
// protocol, as a `deco serve` would on a developer's machine. A sandbox-less
// session has no daemon: 404, which the editor reads as "no protocol".
app.post(
"/:virtualMcpId/:branch/rpc",
bodyLimit({
maxSize: CONTENT_RPC_MAX_BODY_BYTES,
onError: (c) =>
c.json(
{ error: "Payload too large" },
413,
SANDBOX_PROXY_CACHE_HEADERS,
),
}),
proxyContentRpc,
);
app.put(
"/:virtualMcpId/:branch/assets/:name",
bodyLimit({
maxSize: CONTENT_ASSET_MAX_BODY_BYTES,
onError: (c) =>
c.json(
{ error: "Payload too large" },
413,
SANDBOX_PROXY_CACHE_HEADERS,
),
}),
proxyContentAsset,
);

// -- Tenant config --------------------------------------------------------
app.get("/:virtualMcpId/:branch/config", (c) =>
proxyDaemon(c, "/_sandbox/config", {
Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/components/sections-editor/content-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,69 @@ describe("selectContentBackend", () => {
);
});

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");
Expand Down
34 changes: 25 additions & 9 deletions apps/web/src/components/sections-editor/content-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@
* 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 either by
* a `deco serve` on the editor's machine (`local`) or by Studio's GitHub
* backend (`github`).
* 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";
export type ContentSource = "github" | "local" | "sandbox";

export interface ProtocolBackend {
kind: "protocol";
Expand Down Expand Up @@ -110,7 +110,8 @@ export type BackendDecision =
| "pending"
| "legacy"
| "protocol-local"
| "protocol-github";
| "protocol-github"
| "protocol-sandbox";

/**
* Which backend a project's editor uses. Outside a project (forms with no
Expand All @@ -122,9 +123,10 @@ export type BackendDecision =
* 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). Sandbox
* sessions stay legacy: their pod's working tree shares the branch, and
* commits from here would make the two diverge.
* 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). */
Expand All @@ -136,10 +138,24 @@ export function selectContentBackend(input: {
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 || input.runtime !== "cms") return "legacy";
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";
Expand Down
Loading
Loading