mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 14:01:09 +00:00
SDK: Add ensureServer() for automatic server recovery
Add ensureServer() to SandboxProvider interface to handle cases where the sandbox-agent server stops or goes to sleep. The SDK now calls this method after 3 consecutive health-check failures, allowing providers to restart the server if needed. Most built-in providers (E2B, Daytona, Vercel, Modal, ComputeSDK) implement this. Docker and Cloudflare manage server lifecycle differently, and Local uses managed child processes. Also update docs for quickstart, architecture, multiplayer, and session persistence; mark persist-* packages as deprecated; and add ensureServer implementations to all applicable providers. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d008283c17
commit
35840facdd
38 changed files with 620 additions and 205 deletions
154
examples/cloudflare/tests/cloudflare.test.ts
Normal file
154
examples/cloudflare/tests/cloudflare.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_DIR = resolve(__dirname, "..");
|
||||
|
||||
/**
|
||||
* Cloudflare Workers integration test.
|
||||
*
|
||||
* Set RUN_CLOUDFLARE_EXAMPLES=1 to enable. Requires wrangler and Docker.
|
||||
*
|
||||
* This starts `wrangler dev` which:
|
||||
* 1. Builds the Dockerfile (cloudflare/sandbox base + sandbox-agent)
|
||||
* 2. Starts a local Workers runtime with Durable Objects and containers
|
||||
* 3. Exposes the app on a local port
|
||||
*
|
||||
* We then test through the proxy endpoint which forwards to sandbox-agent
|
||||
* running inside the container.
|
||||
*/
|
||||
const shouldRun = process.env.RUN_CLOUDFLARE_EXAMPLES === "1";
|
||||
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 600_000;
|
||||
|
||||
const testFn = shouldRun ? it : it.skip;
|
||||
|
||||
interface WranglerDev {
|
||||
baseUrl: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
async function startWranglerDev(): Promise<WranglerDev> {
|
||||
// Build frontend assets first (wrangler expects dist/ to exist)
|
||||
execSync("npx vite build", { cwd: PROJECT_DIR, stdio: "pipe" });
|
||||
|
||||
return new Promise<WranglerDev>((resolve, reject) => {
|
||||
const child: ChildProcess = spawn("npx", ["wrangler", "dev", "--port", "0"], {
|
||||
cwd: PROJECT_DIR,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
// Ensure wrangler picks up API keys to pass to the container
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let resolved = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (child.pid) {
|
||||
// Kill process group to ensure wrangler and its children are cleaned up
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
} catch {
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(new Error(`wrangler dev did not start within 120s.\nstdout: ${stdout}\nstderr: ${stderr}`));
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stdout += text;
|
||||
|
||||
// wrangler dev prints "Ready on http://localhost:XXXX" when ready
|
||||
const match = stdout.match(/Ready on (https?:\/\/[^\s]+)/i) ?? stdout.match(/(https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/);
|
||||
if (match && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ baseUrl: match[1], cleanup });
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", onData);
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
stderr += text;
|
||||
// Some wrangler versions print ready message to stderr
|
||||
const match = text.match(/Ready on (https?:\/\/[^\s]+)/i) ?? text.match(/(https?:\/\/(?:localhost|127\.0\.0\.1):\d+)/);
|
||||
if (match && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ baseUrl: match[1], cleanup });
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`wrangler dev failed to start: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`wrangler dev exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("cloudflare example", () => {
|
||||
testFn(
|
||||
"starts wrangler dev and sandbox-agent responds via proxy",
|
||||
async () => {
|
||||
const { baseUrl, cleanup } = await startWranglerDev();
|
||||
try {
|
||||
// The Cloudflare example proxies requests through /sandbox/:name/proxy/*
|
||||
// Wait for the container inside the Durable Object to start sandbox-agent
|
||||
const healthUrl = `${baseUrl}/sandbox/test/proxy/v1/health`;
|
||||
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
try {
|
||||
const res = await fetch(healthUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// The proxied health endpoint returns {name: "Sandbox Agent", ...}
|
||||
if (data.status === "ok" || data.name === "Sandbox Agent") {
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
expect(healthy).toBe(true);
|
||||
|
||||
// Confirm a second request also works
|
||||
const response = await fetch(healthUrl);
|
||||
expect(response.ok).toBe(true);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
});
|
||||
8
examples/cloudflare/vitest.config.ts
Normal file
8
examples/cloudflare/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: ".",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildHeaders } from "@sandbox-agent/example-shared";
|
||||
import { setupDockerSandboxAgent } from "../src/docker.ts";
|
||||
import { startDockerSandbox } from "@sandbox-agent/example-shared/docker";
|
||||
|
||||
const shouldRun = process.env.RUN_DOCKER_EXAMPLES === "1";
|
||||
/**
|
||||
* Docker integration test.
|
||||
*
|
||||
* Set SANDBOX_AGENT_DOCKER_IMAGE to the image tag to test (e.g. a locally-built
|
||||
* full image). The test starts a container from that image, waits for
|
||||
* sandbox-agent to become healthy, and validates the /v1/health endpoint.
|
||||
*/
|
||||
const image = process.env.SANDBOX_AGENT_DOCKER_IMAGE;
|
||||
const shouldRun = Boolean(image);
|
||||
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;
|
||||
|
||||
const testFn = shouldRun ? it : it.skip;
|
||||
|
|
@ -11,11 +18,29 @@ describe("docker example", () => {
|
|||
testFn(
|
||||
"starts sandbox-agent and responds to /v1/health",
|
||||
async () => {
|
||||
const { baseUrl, token, cleanup } = await setupDockerSandboxAgent();
|
||||
const { baseUrl, cleanup } = await startDockerSandbox({
|
||||
port: 2468,
|
||||
image: image!,
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/v1/health`, {
|
||||
headers: buildHeaders({ token }),
|
||||
});
|
||||
// Wait for health check
|
||||
let healthy = false;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1/health`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.status === "ok") {
|
||||
healthy = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
expect(healthy).toBe(true);
|
||||
|
||||
const response = await fetch(`${baseUrl}/v1/health`);
|
||||
expect(response.ok).toBe(true);
|
||||
const data = await response.json();
|
||||
expect(data.status).toBe("ok");
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
await this.ready();
|
||||
|
||||
const result = await this.pool.query<SessionRow>(
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
|
||||
FROM ${this.table("sessions")}
|
||||
WHERE id = $1`,
|
||||
[id],
|
||||
|
|
@ -57,7 +57,7 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
const limit = normalizeLimit(request.limit);
|
||||
|
||||
const rowsResult = await this.pool.query<SessionRow>(
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
|
||||
FROM ${this.table("sessions")}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
|
|
@ -79,8 +79,8 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
|
||||
await this.pool.query(
|
||||
`INSERT INTO ${this.table("sessions")} (
|
||||
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
agent = EXCLUDED.agent,
|
||||
agent_session_id = EXCLUDED.agent_session_id,
|
||||
|
|
@ -88,7 +88,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
created_at = EXCLUDED.created_at,
|
||||
destroyed_at = EXCLUDED.destroyed_at,
|
||||
sandbox_id = EXCLUDED.sandbox_id,
|
||||
session_init_json = EXCLUDED.session_init_json`,
|
||||
session_init_json = EXCLUDED.session_init_json,
|
||||
config_options_json = EXCLUDED.config_options_json,
|
||||
modes_json = EXCLUDED.modes_json`,
|
||||
[
|
||||
session.id,
|
||||
session.agent,
|
||||
|
|
@ -97,7 +99,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
session.createdAt,
|
||||
session.destroyedAt ?? null,
|
||||
session.sandboxId ?? null,
|
||||
session.sessionInit ?? null,
|
||||
session.sessionInit ? JSON.stringify(session.sessionInit) : null,
|
||||
session.configOptions ? JSON.stringify(session.configOptions) : null,
|
||||
session.modes !== undefined ? JSON.stringify(session.modes) : null,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -174,7 +178,9 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
created_at BIGINT NOT NULL,
|
||||
destroyed_at BIGINT,
|
||||
sandbox_id TEXT,
|
||||
session_init_json JSONB
|
||||
session_init_json JSONB,
|
||||
config_options_json JSONB,
|
||||
modes_json JSONB
|
||||
)
|
||||
`);
|
||||
|
||||
|
|
@ -183,6 +189,16 @@ export class PostgresSessionPersistDriver implements SessionPersistDriver {
|
|||
ADD COLUMN IF NOT EXISTS sandbox_id TEXT
|
||||
`);
|
||||
|
||||
await this.pool.query(`
|
||||
ALTER TABLE ${this.table("sessions")}
|
||||
ADD COLUMN IF NOT EXISTS config_options_json JSONB
|
||||
`);
|
||||
|
||||
await this.pool.query(`
|
||||
ALTER TABLE ${this.table("sessions")}
|
||||
ADD COLUMN IF NOT EXISTS modes_json JSONB
|
||||
`);
|
||||
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${this.table("events")} (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
|
@ -238,6 +254,8 @@ type SessionRow = {
|
|||
destroyed_at: string | number | null;
|
||||
sandbox_id: string | null;
|
||||
session_init_json: unknown | null;
|
||||
config_options_json: unknown | null;
|
||||
modes_json: unknown | null;
|
||||
};
|
||||
|
||||
type EventRow = {
|
||||
|
|
@ -260,6 +278,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
|
|||
destroyedAt: row.destroyed_at === null ? undefined : parseInteger(row.destroyed_at),
|
||||
sandboxId: row.sandbox_id ?? undefined,
|
||||
sessionInit: row.session_init_json ? (row.session_init_json as SessionRecord["sessionInit"]) : undefined,
|
||||
configOptions: row.config_options_json ? (row.config_options_json as SessionRecord["configOptions"]) : undefined,
|
||||
modes: row.modes_json ? (row.modes_json as SessionRecord["modes"]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
async getSession(id: string): Promise<SessionRecord | undefined> {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
|
||||
FROM sessions WHERE id = ?`,
|
||||
)
|
||||
.get(id) as SessionRow | undefined;
|
||||
|
|
@ -36,7 +36,7 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
|
||||
`SELECT id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
|
||||
FROM sessions
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
|
|
@ -56,8 +56,8 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO sessions (
|
||||
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
id, agent, agent_session_id, last_connection_id, created_at, destroyed_at, sandbox_id, session_init_json, config_options_json, modes_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
agent = excluded.agent,
|
||||
agent_session_id = excluded.agent_session_id,
|
||||
|
|
@ -65,7 +65,9 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
created_at = excluded.created_at,
|
||||
destroyed_at = excluded.destroyed_at,
|
||||
sandbox_id = excluded.sandbox_id,
|
||||
session_init_json = excluded.session_init_json`,
|
||||
session_init_json = excluded.session_init_json,
|
||||
config_options_json = excluded.config_options_json,
|
||||
modes_json = excluded.modes_json`,
|
||||
)
|
||||
.run(
|
||||
session.id,
|
||||
|
|
@ -76,6 +78,8 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
session.destroyedAt ?? null,
|
||||
session.sandboxId ?? null,
|
||||
session.sessionInit ? JSON.stringify(session.sessionInit) : null,
|
||||
session.configOptions ? JSON.stringify(session.configOptions) : null,
|
||||
session.modes !== undefined ? JSON.stringify(session.modes) : null,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -134,7 +138,9 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
created_at INTEGER NOT NULL,
|
||||
destroyed_at INTEGER,
|
||||
sandbox_id TEXT,
|
||||
session_init_json TEXT
|
||||
session_init_json TEXT,
|
||||
config_options_json TEXT,
|
||||
modes_json TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
|
|
@ -142,6 +148,12 @@ export class SQLiteSessionPersistDriver implements SessionPersistDriver {
|
|||
if (!sessionColumns.some((column) => column.name === "sandbox_id")) {
|
||||
this.db.exec(`ALTER TABLE sessions ADD COLUMN sandbox_id TEXT`);
|
||||
}
|
||||
if (!sessionColumns.some((column) => column.name === "config_options_json")) {
|
||||
this.db.exec(`ALTER TABLE sessions ADD COLUMN config_options_json TEXT`);
|
||||
}
|
||||
if (!sessionColumns.some((column) => column.name === "modes_json")) {
|
||||
this.db.exec(`ALTER TABLE sessions ADD COLUMN modes_json TEXT`);
|
||||
}
|
||||
|
||||
this.ensureEventsTable();
|
||||
}
|
||||
|
|
@ -233,6 +245,8 @@ type SessionRow = {
|
|||
destroyed_at: number | null;
|
||||
sandbox_id: string | null;
|
||||
session_init_json: string | null;
|
||||
config_options_json: string | null;
|
||||
modes_json: string | null;
|
||||
};
|
||||
|
||||
type EventRow = {
|
||||
|
|
@ -260,6 +274,8 @@ function decodeSessionRow(row: SessionRow): SessionRecord {
|
|||
destroyedAt: row.destroyed_at ?? undefined,
|
||||
sandboxId: row.sandbox_id ?? undefined,
|
||||
sessionInit: row.session_init_json ? (JSON.parse(row.session_init_json) as SessionRecord["sessionInit"]) : undefined,
|
||||
configOptions: row.config_options_json ? (JSON.parse(row.config_options_json) as SessionRecord["configOptions"]) : undefined,
|
||||
modes: row.modes_json ? (JSON.parse(row.modes_json) as SessionRecord["modes"]) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,11 +78,11 @@ function readClaudeCredentialFiles(): ClaudeCredentialFile[] {
|
|||
const candidates: Array<{ hostPath: string; containerPath: string }> = [
|
||||
{
|
||||
hostPath: path.join(homeDir, ".claude", ".credentials.json"),
|
||||
containerPath: "/root/.claude/.credentials.json",
|
||||
containerPath: ".claude/.credentials.json",
|
||||
},
|
||||
{
|
||||
hostPath: path.join(homeDir, ".claude-oauth-credentials.json"),
|
||||
containerPath: "/root/.claude-oauth-credentials.json",
|
||||
containerPath: ".claude-oauth-credentials.json",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -180,10 +180,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
const credentialBootstrapCommands = claudeCredentialFiles.flatMap((file, index) => {
|
||||
const envKey = `SANDBOX_AGENT_CLAUDE_CREDENTIAL_${index}_B64`;
|
||||
bootstrapEnv[envKey] = file.base64Content;
|
||||
return [
|
||||
`mkdir -p ${shellSingleQuotedLiteral(path.posix.dirname(file.containerPath))}`,
|
||||
`printf %s "$${envKey}" | base64 -d > ${shellSingleQuotedLiteral(file.containerPath)}`,
|
||||
];
|
||||
// Use $HOME-relative paths so credentials work regardless of container user
|
||||
const containerDir = path.posix.dirname(file.containerPath);
|
||||
return [`mkdir -p "$HOME/${containerDir}"`, `printf %s "$${envKey}" | base64 -d > "$HOME/${file.containerPath}"`];
|
||||
});
|
||||
setupCommands.unshift(...credentialBootstrapCommands);
|
||||
}
|
||||
|
|
@ -200,8 +199,9 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
|
||||
const container = await docker.createContainer({
|
||||
Image: image,
|
||||
Entrypoint: ["/bin/sh", "-c"],
|
||||
WorkingDir: "/home/sandbox",
|
||||
Cmd: ["sh", "-c", bootCommands.join(" && ")],
|
||||
Cmd: [bootCommands.join(" && ")],
|
||||
Env: [...Object.entries(credentialEnv).map(([key, value]) => `${key}=${value}`), ...Object.entries(bootstrapEnv).map(([key, value]) => `${key}=${value}`)],
|
||||
ExposedPorts: { [`${port}/tcp`]: {} },
|
||||
HostConfig: {
|
||||
|
|
@ -253,10 +253,13 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise<Do
|
|||
try {
|
||||
await container.remove({ force: true });
|
||||
} catch {}
|
||||
};
|
||||
const signalCleanup = async () => {
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", cleanup);
|
||||
process.once("SIGTERM", cleanup);
|
||||
process.once("SIGINT", signalCleanup);
|
||||
process.once("SIGTERM", signalCleanup);
|
||||
|
||||
return { baseUrl, cleanup };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue