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
66 changes: 59 additions & 7 deletions packages/vercel-sandbox/src/api-client/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,7 @@ describe("APIClient", () => {
expect.fail("Expected APIError to be thrown");
} catch (err) {
expect(err).toBeInstanceOf(APIError);
expect(err.message).toBe(
"Status code 410 is not ok",
);
expect(err.message).toBe("Status code 410 is not ok");
expect(err.json).toEqual({
error: "gone",
});
Expand Down Expand Up @@ -270,14 +268,69 @@ describe("APIClient", () => {
expect.fail("Expected APIError to be thrown");
} catch (err) {
expect(err).toBeInstanceOf(APIError);
expect(err.message).toBe(
"Status code 410 is not ok",
);
expect(err.message).toBe("Status code 410 is not ok");
expect(err.json).toEqual({
error: "gone",
});
}
});

it("returns synthetic command when non-wait response body is empty (background process)", async () => {
mockFetch.mockResolvedValue(
new Response("", {
status: 200,
headers: { "content-type": "application/json" },
}),
);

const result = await client.runCommand({
sessionId: "sbx_123",
command: "sleep",
args: ["10"],
env: {},
sudo: false,
cwd: "/home",
});

expect(result.json.command.name).toBe("sleep");
expect(result.json.command.args).toEqual(["10"]);
expect(result.json.command.sessionId).toBe("sbx_123");
expect(result.json.command.cwd).toBe("/home");
expect(result.json.command.exitCode).toBeNull();
expect(result.text).toBe("");
});

it("parses normal non-wait response correctly", async () => {
const commandData = {
command: {
id: "cmd_456",
name: "ls",
args: ["-la"],
cwd: "/",
sessionId: "sbx_123",
exitCode: null,
startedAt: 1,
},
};

mockFetch.mockResolvedValue(
new Response(JSON.stringify(commandData), {
status: 200,
headers: { "content-type": "application/json" },
}),
);

const result = await client.runCommand({
sessionId: "sbx_123",
command: "ls",
args: ["-la"],
env: {},
sudo: false,
});

expect(result.json.command.id).toBe("cmd_456");
expect(result.json.command.name).toBe("ls");
});
});

describe("stopSession", () => {
Expand Down Expand Up @@ -891,6 +944,5 @@ describe("APIClient", () => {
JSON.stringify({ expiration: 0 }),
);
});

});
});
95 changes: 67 additions & 28 deletions packages/vercel-sandbox/src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
type RequestParams,
} from "./base-client";
import {
type CommandFinishedData,
type CommandFinishedData,
SessionAndRoutesResponse,
SessionResponse,
SessionsResponse,
Expand Down Expand Up @@ -143,9 +143,12 @@ export class APIClient extends BaseClient {
querystring = querystring ? `?${querystring}` : "";
return parseOrThrow(
SessionAndRoutesResponse,
await this.request(`/v2/sandboxes/sessions/${params.sessionId}${querystring}`, {
signal: params.signal,
}),
await this.request(
`/v2/sandboxes/sessions/${params.sessionId}${querystring}`,
{
signal: params.signal,
},
),
);
}

Expand Down Expand Up @@ -284,9 +287,9 @@ export class APIClient extends BaseClient {
return { command, finished };
}

return parseOrThrow(
CommandResponse,
await this.request(`/v2/sandboxes/sessions/${params.sessionId}/cmd`, {
const response = await this.request(
`/v2/sandboxes/sessions/${params.sessionId}/cmd`,
{
method: "POST",
body: JSON.stringify({
command: params.command,
Expand All @@ -296,8 +299,32 @@ export class APIClient extends BaseClient {
sudo: params.sudo,
}),
signal: params.signal,
}),
},
);

// Background commands (e.g. `cmd &`) may return 200 with an empty body.
// Clone the response so we can inspect the text without consuming it.
const cloned = response.clone();
const text = await cloned.text();
if (response.ok && text.trim() === "") {
return {
json: {
command: {
id: "",
name: params.command,
args: params.args,
cwd: params.cwd ?? "",
sessionId: params.sessionId,
exitCode: null,
startedAt: Date.now(),
},
},
response,
text: "",
};
}

return parseOrThrow(CommandResponse, response);
}

async getCommand(params: {
Expand Down Expand Up @@ -343,11 +370,14 @@ export class APIClient extends BaseClient {
}) {
return parseOrThrow(
EmptyResponse,
await this.request(`/v2/sandboxes/sessions/${params.sessionId}/fs/mkdir`, {
method: "POST",
body: JSON.stringify({ path: params.path, cwd: params.cwd }),
signal: params.signal,
}),
await this.request(
`/v2/sandboxes/sessions/${params.sessionId}/fs/mkdir`,
{
method: "POST",
body: JSON.stringify({ path: params.path, cwd: params.cwd }),
signal: params.signal,
},
),
);
}

Expand All @@ -359,15 +389,18 @@ export class APIClient extends BaseClient {
const writer = new FileWriter();
return {
response: (async () => {
return this.request(`/v2/sandboxes/sessions/${params.sessionId}/fs/write`, {
method: "POST",
headers: {
"content-type": "application/gzip",
"x-cwd": params.extractDir,
return this.request(
`/v2/sandboxes/sessions/${params.sessionId}/fs/write`,
{
method: "POST",
headers: {
"content-type": "application/gzip",
"x-cwd": params.extractDir,
},
body: await consumeReadable(writer.readable),
signal: params.signal,
},
body: await consumeReadable(writer.readable),
signal: params.signal,
});
);
})(),
writer,
};
Expand Down Expand Up @@ -613,7 +646,11 @@ export class APIClient extends BaseClient {

if (params.blocking) {
let session = response.json.session;
while (session.status !== "stopped" && session.status !== "failed" && session.status !== "aborted") {
while (
session.status !== "stopped" &&
session.status !== "failed" &&
session.status !== "aborted"
) {
await setTimeout(500, undefined, { signal: params.signal });
const poll = await this.getSession({
sessionId: params.sessionId,
Expand Down Expand Up @@ -701,12 +738,14 @@ export class APIClient extends BaseClient {
);
}

async getSandbox(params: WithPrivate<{
name: string;
projectId: string;
resume?: boolean;
signal?: AbortSignal;
}>) {
async getSandbox(
params: WithPrivate<{
name: string;
projectId: string;
resume?: boolean;
signal?: AbortSignal;
}>,
) {
const privateParams = getPrivateParams(params);
const query: Record<string, string | undefined> = {
projectId: params.projectId,
Expand Down
Loading