Skip to content
Open
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);
});
});
133 changes: 120 additions & 13 deletions apps/api/src/api/routes/sandbox-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ import {
parseLoaderInvokeRequest,
} from "../../lib/loader-invoke";
import { resolvePreviewServerUrl } from "@decocms/shared/deco-site-production-url";
import {
draftGitDiscard,
hostedDraftPublishDiff,
hostedDraftPublishStatus,
loadHostedDraft,
} from "../../hosted/draft-publish-status";
import {
GitPushAuthError,
parseRepositoryBinding,
Expand Down Expand Up @@ -130,6 +136,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 @@ -393,6 +406,44 @@ async function fastPreviewGitClient(c: Context<VmEnv>) {
return contentClientForProjectRepo(ctx, organization.id, repository);
}

/** The hosted v8 project's draft (see hosted/draft-publish-status.ts), or null. */
function loadHostedDraftFor(c: Context<VmEnv>) {
const claim = c.get("vmClaim");
const ctx = c.var.studioContext;
return loadHostedDraft({
storage: ctx.storage,
organizationId: requireOrganization(ctx).id,
virtualMcpId: claim.virtualMcpId,
branch: claim.branch,
metadata: claim.virtualMcpMetadata,
gitClient: () => fastPreviewGitClient(c),
});
}

/** `/git/status` without a sandbox: the branch's drift, or a hosted draft's. */
async function fastPreviewStatus(c: Context<VmEnv>) {
const hosted = await loadHostedDraftFor(c);
if (hosted) {
return hostedDraftPublishStatus(
hosted.repo,
hosted.ref.branch,
hosted.draft,
);
}
return repoGitStatus(await fastPreviewGitClient(c), c.get("vmClaim").branch);
}

/** `/git/diff` without a sandbox: the branch's bodies, or a hosted draft's. */
async function fastPreviewDiff(c: Context<VmEnv>, base?: string) {
const hosted = await loadHostedDraftFor(c);
if (hosted) return hostedDraftPublishDiff(hosted.repo, hosted.draft);
return repoGitDiff(
await fastPreviewGitClient(c),
c.get("vmClaim").branch,
base,
);
}

function fastPreviewGitError(c: Context<VmEnv>, err: unknown): Response {
const message = err instanceof Error ? err.message : String(err);
/** 429 with the provider's own wait, so the client backs off instead of
Expand Down Expand Up @@ -458,8 +509,7 @@ async function proxyPreviewUpstream(

async function fastPreviewGitStatus(c: Context<VmEnv>): Promise<Response> {
try {
const client = await fastPreviewGitClient(c);
const status = await repoGitStatus(client, c.get("vmClaim").branch);
const status = await fastPreviewStatus(c);
return c.json(status, 200, SANDBOX_PROXY_CACHE_HEADERS);
} catch (err) {
return fastPreviewGitError(c, err);
Expand Down Expand Up @@ -512,6 +562,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 +595,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 +712,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 +883,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 Expand Up @@ -955,8 +1064,7 @@ export const createSandboxRoutes = () => {
const body = (await c.req.json().catch(() => ({}))) as {
base?: string;
};
const client = await fastPreviewGitClient(c);
const diff = await repoGitDiff(client, claim.branch, body.base);
const diff = await fastPreviewDiff(c, body.base);
return c.json(diff, 200, SANDBOX_PROXY_CACHE_HEADERS);
} catch (err) {
return fastPreviewGitError(c, err);
Expand Down Expand Up @@ -1040,6 +1148,11 @@ export const createSandboxRoutes = () => {
);
}
try {
const hosted = await loadHostedDraftFor(c);
if (hosted) {
await draftGitDiscard(hosted.drafts, hosted.ref, filepaths);
return c.json({ ok: true }, 200, SANDBOX_PROXY_CACHE_HEADERS);
}
const client = await fastPreviewGitClient(c);
await repoGitDiscard(client, claim.branch, filepaths);
return c.json({ ok: true }, 200, SANDBOX_PROXY_CACHE_HEADERS);
Expand Down Expand Up @@ -1167,13 +1280,7 @@ export const createSandboxRoutes = () => {
])
: // Sandbox-less backfill: same GitHub-backed shapes the /git
// routes serve (no daemon exists to ask).
await (async () => {
const client = await fastPreviewGitClient(c);
return Promise.all([
repoGitStatus(client, claim.branch),
repoGitDiff(client, claim.branch),
]);
})();
await Promise.all([fastPreviewStatus(c), fastPreviewDiff(c)]);
// The only route under /sandbox that spends real money: one model call
// on the org's gateway credential, with a prompt the caller sizes (up
// to the body limit above). It is a plain BFF route, so `defineTool`'s
Expand Down
4 changes: 1 addition & 3 deletions knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@
"ignoreDependencies": ["@types/*"],
// TODO(@camudo): Remove this config once I fix all unused type exports
"ignoreIssues": {
"**/*": ["types"],
// Temporary (split stack): hosted modules land before their routes (removed when /_sandbox/rpc lands).
"apps/api/src/hosted/*.ts": ["exports"]
"**/*": ["types"]
}
}
Loading