freeCodeCamp.org

How to Build Your Own AI App Builder Like Lovable with Next.js, AWS and Sandboxes

7.1内容质量
How to Build Your Own AI App Builder Like Lovable with Next.js, AWS and Sandboxes

TL;DR · AI 摘要

How to Build Your Own AI App Builder Like Lovable with Next.js, AWS and Sandboxes October 2, 2026 / AI Shrijal Acharya T...

核心要点

  • 主题聚焦:How to Build Your Own AI App Builder Like Lovabl
  • 来源:freeCodeCamp.org,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#前端#后端#云计算
打开原文

如何使用 Next.js、AWS 和沙箱构建自己的 AI 应用构建器(如 Lovable)

2026年10月2日

/

#AI

Shrijal Acharya

第一次使用 Lovable、Bolt 或 v0 这类工具时,它们看起来有点像魔法。老实说,我第一次看到类似的东西时感到非常震惊……而且你还可以通过聊天窗口完成所有操作!这简直太疯狂了。我第一次尝试 Lovable 时甚至产生了存在主义危机。

但你有没有想过这些工具背后到底发生了什么?比如,为什么一个应用能够快速设置好另一个可以测试、分享、下载等的完整应用?

显然,某个 AI 模型正在生成代码。但具体是如何实现的?这些代码需要被安装、构建和运行。让我有些怀疑的是,如今似乎没人会在代码运行前检查它。它可能有错误、运行缓慢,甚至可能尝试执行危险操作(如用 rm -rf . 删除整个系统)。我们已经经历过 AI 生成代码导致此类问题的情况,因此不能完全信任。

本文将解决这个问题。

在这里,你将构建一个类似 Lovable 的 AI 应用构建器。这不是一个克隆项目,而是深入探讨 Lovable 的原理,了解它真正的工作方式。我们将保持用户界面相对基础。

对于我们的项目,用户可以通过普通英语描述一个应用,AI 代理将在隔离的云沙箱中生成代码。AI 会自行修复错误,然后显示实时预览。从那里,用户可以通过聊天继续构建应用,回滚到任何版本,并通过单击发布最终应用。

本文内容:

在本教程中,你将从零开始构建整个系统。以下是学习过程中将掌握的内容:

  • 为什么 AI 生成的代码需要沙箱环境
  • 如何通过内存快照在约 4 秒内(而非 30+ 秒)快速准备新沙箱
  • 如何构建一个代理循环,使其能够编写代码、检查自身工作并修复错误
  • 如何实时将代理操作流式传输到浏览器(页面刷新时不会丢失)
  • 如何通过自己的网关实现热重载的实时预览
  • 如何在不将任何令牌放入沙箱的情况下,使用 Git 对每次更改进行版本控制
  • 如何让闲置沙箱进入休眠状态,在需要时唤醒并发布最终应用

这些内容涉及一些高级概念,但只要跟随教程,你将学到很多。在构建过程中,我自己也学到了很多。 😉

目录

  • 计划是什么(架构)
  • 为什么需要沙箱?
  • 如何设置项目
  • 应用的核心组件:快速启动沙箱、代理循环、让代理检查自身工作、将进度流式传输到浏览器、通过网关实现实时预览、使用 Git 进行版本控制、让沙箱休眠、唤醒并共享、发布应用
  • 应用构建器实战演示
  • 总结

计划是什么(架构)

在深入代码之前,了解整体架构很有帮助,因为有一些概念需要提前理解。

该应用分为三个进程和一些基础设施组件。从一开始我就非常坚持一个原则:Web 应用本身绝不运行 AI 代理,代理也绝不运行在沙箱内部。稍后你会明白为什么这个原则很重要。

以下是完整的流程:

发送提示

当用户输入提示时,Web 应用(Next.js)会保存提示内容,在 Postgres 中创建一个“运行”记录,将任务加入队列,并立即返回响应。因此用户无需等待 HTTP 请求完成,而可以继续进行其他操作。

运行代理

一个工作进程会获取该任务。首先它会确认项目是否已启动沙箱环境,然后启动代理循环。LLM 决定要执行的操作,它所调用的每个工具(如写入文件、运行命令、安装包等)都会在沙箱内部实际执行。

每个步骤也会作为事件保存到数据库中,浏览器正是通过这些事件实时获取更新内容。

预览展示

生成的应用在沙箱内部运行自己的 Vite 开发服务器。我们有一个小型网关服务,将 http://<project-id>.preview.localhost:4000 的请求代理到该开发服务器,包括 Vite 用于热重载的 WebSocket 连接。

因此当代理修改文件时,预览界面会自动更新,每次看到这个效果我仍然觉得相当酷。

保存与发布

当代理完成任务后,工作进程会将更改提交为新版本。当用户点击“发布”时,应用会被构建,静态文件上传到 S3,这样即使沙箱处于休眠状态,已发布的网站仍可正常运行。

这基本上就是我们应用的高层架构。简单来说:

  • Next.js:UI 和 API 层
  • Worker(Node.js + pg-boss):代理层
  • 网关(Node.js 代理):预览层
  • Postgres:状态和任务队列层
  • 云沙箱:执行层
  • S3(本地使用 MinIO):存储层
  • 任意 LLM(我们使用的是 Claude 或 GPT):推理层

为什么需要沙箱?

仔细想想,整个产品本质上就是“运行未经审查的代码”。代理编写代码,npm install 会引入其安装脚本可以执行几乎任何操作的包,然后开发服务器会启动并执行所有内容。我绝不会在自己的服务器上运行这些代码,你也不应该这么做。

因此每个项目都会获得一个独立的沙箱,这基本上就是云中的一个小型虚拟机。在选择沙箱提供商时,我实际需要的功能包括:

  • 挂起与恢复:大多数项目大部分时间处于空闲状态。我希望能在几秒钟内将它们休眠并恢复,同时保持内存状态。
  • 文件、命令和终端:代理需要读写文件并运行命令,同时给用户提供一个真正的 shell 也很方便。

💁 在生产环境中使用此类服务时还有许多其他需要考虑的因素,但这些是我唯一硬性需求。

为此我使用的是 Tensorlake 沙箱。需要明确的是,选择这个特定提供商并没有特别原因。E2B、Daytona、Modal 或者你自己的 Firecracker 部署都可以正常工作,你可以自由选择任何方案。我之前已经在几个项目中使用它,对于沙箱需求来说它表现得非常完美,尤其是像我们这样的使用场景。

注意:所有沙箱代码都位于一个单独的包中(packages/sandbox)。因此如果你想切换提供商,只需修改这个位置即可。应用的其他部分甚至不知道正在与哪个提供商通信。

如何设置项目

开始之前,请确保已安装以下内容:

  • Node.js 22 或更高版本
  • pnpm
  • Docker(如果你计划先在本地进行测试,需要安装用于 Postgres 和 MinIO)

你还需要为沙箱提供商和一个LLM(Anthropic或OpenAI,任选其一)准备API密钥。

首先克隆仓库并安装依赖项:

code
git clone https://github.com/shricodev/lovable-build-tensorlake-aws.git
cd lovable-build-tensorlake-aws
pnpm install

接下来创建环境文件并填写密钥:

code
cp .env.example .env
# 添加你的沙箱和LLM密钥,并通过以下方式生成 AUTH_SECRET:
openssl rand -base64 32

现在启动本地基础设施、创建数据库表并设置存储桶:

code
# 启动Postgres和MinIO
pnpm infra:up

# 创建数据库表
pnpm db:migrate

# 创建存储桶并进行权限限制
pnpm s3:setup

然后构建所有新项目的基础快照(稍后会详细介绍)。构建过程大约需要45秒:

code
pnpm sandbox:build-base

最后启动所有服务:

code
# web服务运行在:3000,预览网关运行在:4000
pnpm dev

打开 http://localhost:3000,登录并描述一个应用。完成!🎉

注意:该应用支持GitHub登录,但同时也提供了简单的开发环境登录方式,这样你可以在创建GitHub OAuth应用之前先进行体验。不用担心,开发环境登录在生产环境中始终是禁用的。

应用中的核心组件

这个项目规模很大。逐行讲解每个细节会使文档变得冗长,因此我将重点介绍真正使系统运行的核心组件。仪表盘、登录和代码编辑器等组件属于比较标准的功能,我将略过这些部分。

注意:这意味着下面的代码片段已精简为关键部分。完整代码可在仓库中找到。

快速启动沙箱

每个生成的应用都基于相同的模板:Vite、React、TypeScript、Tailwind以及一些常用库。最简单直接的方式是为每个新项目都执行以下操作:

  • 创建新的沙箱
  • 上传模板
  • 运行npm install
  • 启动开发服务器

这种方式虽然可行,但在我测试中,预览功能直到33.4秒后才变得可用。其中大部分时间(约23秒)都消耗在单个vCPU上运行npm install。我不确定你是否愿意每次启动新项目时都盯着加载指示器等待这么久。

解决方案是将所有这些步骤只执行一次,然后将结果保存为内存快照。内存快照会捕获文件、内存和运行中的进程。当你恢复快照时,开发服务器已经处于运行状态,无需重新启动任何组件。

基础快照构建器的核心代码如下:

code
export async function buildBaseSnapshot(log: Logger) {
  // 冷启动路径,仅执行一次:创建沙箱、上传模板、npm install、git init,
  // 验证构建结果,启动开发服务器并预热Vite缓存
  const { ps } = await coldCreateFromTemplate({
    name: `base-${Date.now()}`,
    log,
    verify: true,
  });

  try {
    // 内存检查点:保存文件、内存和运行中的进程
    const snapshotId = await ps.checkpoint();
    writeBaseSnapshot({ snapshotId, createdAt: new Date().toISOString() });
  } finally {
    await ps.terminate();
  }
}

有了这个实现,为新项目创建沙箱只需要恢复快照,然后进行权限限制:

code
static async createFromSnapshot(opts: { snapshotId: string; name: string; log: Logger }) {
  const sb = await Sandbox.create({ snapshotId: opts.snapshotId, name: opts.name, timeoutSecs: 600 });
  const ps = new ProjectSandbox(sb, opts.log);
javascript
await sb.update({
  exposedPorts: [5173],              // 通过代理访问的 Vite 开发服务器
  allowUnauthenticatedAccess: false, // 端口 URL 从不公开
  network: {
    allowInternetAccess: true,
    allowOut: ["registry.npmjs.org"], // 仅允许访问 npm
    denyOut: [],
  },
});
return ps;
}

这里有几个需要注意的要点:

  • exposedPorts 使开发服务器可以通过提供商的代理访问,但前提是拥有我们的 API 密钥。我们稍后在网关部分会使用这个密钥。
  • network 块是一个允许列表。一旦 allowOut 中有条目,其他所有内容都会被阻止。我实际上用 example.com、github.com 和云元数据 IP 测试过这个功能,所有这些都被阻止了,而 npm 仍然可以正常工作。
  • 沙盒环境中完全没有任何敏感信息。没有 LLM 密钥、没有数据库 URL、没有 AWS 密钥,什么都没有。因此,即使生成的应用或某些提示注入尝试窃取信息,里面也没有任何可以窃取的内容。

这是我测得的数据,直到预览实际加载完成为止:

| 路径 | 时间 | |--------------------------|----------| | 冷启动(创建、安装、启动开发服务器) | 33.4 秒 | | 从内存快照恢复 | 4.0 秒 | | 唤醒休眠的沙盒 | 2.4 秒 |

这大约快了 8 倍。这有多酷?😎

代理循环

代理循环是整个系统的"大脑"。每次用户发送提示时,都会运行这个循环。

这个想法很简单,尽管实现起来可能不简单。你给 LLM 一个目标和一些工具,让它调用这些工具,将结果反馈回来,重复这个过程直到完成。

代理可以使用的工具有:

  • list_files、read_file、write_file、edit_file 和 delete_file
  • run_command 用于快速检查,如 npx tsc --noEmit
  • install_packages 用于添加 npm 包
  • get_dev_server_logs 和 get_browser_errors 用于调试
  • finish,当代理认为任务完成时调用

每个工具只是一个带有 Zod 模式和运行函数的小文件。以下是 edit_file 的示例:

javascript
export const editFile = defineTool({
  name: "edit_file",
  description:
    "在文件中替换一个精确的代码片段。`search` 必须完全匹配且只出现一次。",
  schema: z.object({
    path: z.string(),
    search: z.string().min(1),
    replace: z.string(),
  }),
  async run({ path, search, replace }, { sandbox }) {
    const text = await sandbox.readFile(path);
    const count = text.split(search).length - 1;
    if (count !== 1) {
      return {
        isError: true,
        content: `搜索文本在 ${path} 中出现了 ${count} 次`,
      };
    }
    await sandbox.writeFile(
      path,
      text.replace(search, () => replace),
    );
    return { content: `已编辑 ${path}`, changedFiles: [path] };
  },
});

Zod 模式在这里承担了两个任务。它会被转换为 JSON Schema 供 LLM 使用(通过 z.toJSONSchema),同时它也会在任何操作触及沙盒之前验证模型返回的任何内容。如果输入无效,错误会直接返回给模型,而不会导致整个运行崩溃。

然后实际的循环开始运行:

javascript
while (true) {
  if (signal.aborted) return result("cancelled");

  const res = await llm.chat({
    system: SYSTEM_PROMPT,
    messages,
    tools,
    signal,
    onText,
  });
  messages.push(res.message);
code
const results = [];
for (const call of res.message.toolCalls) {
  const out = await executeTool(call.name, call.input, ctx); // 验证 + 执行
  results.push({
    type: "tool_result",
    toolCallId: call.id,
    content: out.content,
    isError: out.isError,
  });
  if (out.finish) finishCalled = true;
}

if (!finishCalled) {
  messages.push({ role: "user", content: results });
  continue;
}

// 代理声称已完成,现在进行验证(下一节)
}

llm.chat 调用是我编写的一个小型封装器,它支持通过相同接口使用 Anthropic 和 OpenAI,因此你可以在 UI 的下拉菜单中切换模型。

在 Anthropic 方面,提示缓存功能已启用,老实说,它在成本控制上承担了大部分工作。一个典型的对话回合会读取超过 120,000 个 token,其中几乎所有 token 都直接来自缓存。

#### 保护文件路径安全

这个技巧有点隐蔽,我之所以发现,是因为我一直在进行探索性检查。沙箱文件 API 会阻止包含 .. 的路径,但它会愉快地跟随符号链接。因此,如果生成的代码创建了一个指向 /etc/passwd 的 leak.txt,读取 leak.txt 会返回密码文件。这不是个好现象。

因此,每个文件工具在接触任何文件之前,都会在沙箱内解析实际路径:

code
async safePath(relPath: string) {
  const abs = resolveProjectPath(relPath); // 拒绝包含 ".."、绝对路径、NUL 字节
  const r = await this.sb.run("realpath", { args: ["-m", "--", abs] });
  const real = r.stdout.trim();
  if (!isInsideApp(real)) throw new SandboxPathError(relPath, "resolves outside the project");
  return real;
}

realpath -m 会跟随所有符号链接,并告诉你路径实际指向的位置。如果最终位置超出项目文件夹,该调用将直接失败。

#### 保持上下文简洁

为了避免在每次新提示中重放所有历史工具调用,每个回合都会从以下内容开始一个全新的对话:

  • 每个早期回合的一行摘要(用户提出了什么,代理做了什么)
  • 文件树和已安装包列表
  • 当前的 src/App.tsx

代理通过其工具读取其他任何需要的信息。这使得即使项目经历了 20 次提示,每个回合的成本仍然基本保持平稳。

让代理自行检查工作

LLM 非常自信。当构建实际上已经崩溃时,它们会很高兴地告诉你一切正常。因此,当代理调用 finish 时,我们不会仅仅相信它的话。我们在沙箱内执行三项检查:

code
export async function runChecks(sandbox: ProjectSandbox) {
  const tsc = await sandbox.exec("npx tsc --noEmit -p . 2>&1", {
    timeoutSecs: 120,
  });
  const build = await sandbox.exec(
    "npx vite build --outDir /tmp/build --emptyOutDir --logLevel error 2>&1",
    { timeoutSecs: 180 },
  );
  const render = await renderCheck(sandbox); // 在虚拟 DOM 中渲染应用一次

  return {
    ok: tsc.exitCode === 0 && build.exitCode === 0 && render.ok,
    typecheck: { ok: tsc.exitCode === 0, output: tsc.stdout },
    build: { ok: build.exitCode === 0, output: build.stdout },
    render,
  };
}

前两个检查相对明显。第三个检查则更有趣。许多错误只有在运行时才会显现,比如 Cannot read properties of undefined (reading 'map')。通常的解决方案是使用无头浏览器,但在小型沙箱中,这会很耗费资源且速度缓慢,我确实不想走这条路。

因此,模板会附带一个微型脚本,该脚本使用 happy-dom(Node.js 的虚拟 DOM)和 Vite 的 ssrLoadModule 一次性渲染应用:

code
GlobalRegistrator.register({ url: "http://localhost:5173/" });
document.body.innerHTML = '<div id="root"></div>';
console.error = (...args) => errors.push(args.join(" "));

await server.ssrLoadModule("/src/main.tsx"); // 运行真实的应用入口
await new Promise((r) => setTimeout(r, 1500)); // 等待 React 渲染

const rendered = document.getElementById("root").innerHTML.trim().length > 0;
console.log(
  JSON.stringify({ ok: rendered && errors.length === 0, rendered, errors }),
);

这个过程大约需要 2 秒,它成功捕获了 undefined.map 崩溃以及 App.tsx 中的具体出错行。太棒了!

如果任何检查失败,错误会直接通过代理返回为新消息,并获得再次修复的机会:

code
lastCheck = await runChecks(sandbox);
if (lastCheck.ok) return result("succeeded");
if (healRounds >= maxHeal)
  return result("failed", { error: describeFailures(lastCheck) });

healRounds++;
messages.push({
  role: "user",
  content: [
    ...results,
    {
      type: "text",
      text: `验证失败。请修复这些问题,然后再次调用 finish。\n\n${describeFailures(lastCheck)}`,
    },
  ],
});

默认情况下会在 3 轮后停止。如果在那之后仍然无法修复,用户会收到诚实的反馈 "我无法完成这个任务" 并附带实际错误信息。

向浏览器流式传输进度

代理在 worker 线程中运行,但用户在查看浏览器。因此,每个步骤("已写入 src/App.tsx"、"运行 npx tsc --noEmit"、"检查通过"等)都必须实时从 worker 传输到浏览器。

worker 将每个步骤作为 run_events 表中的一行记录,然后触发 Postgres NOTIFY:

code
export async function appendEvent(
  db: Db,
  e: { projectId: string; runId?: string; type: string; payload: unknown },
) {
  const [row] = await db
    .insert(runEvents)
    .values(e)
    .returning({ id: runEvents.id });
  await db.$client.notify(
    "events",
    JSON.stringify({ projectId: e.projectId, id: row.id }),
  );
  return row.id;
}

在网页端,Next.js 路由处理器通过 Server Sent Events 将这些事件流式传输到浏览器。当浏览器连接时,它会首先重放当前运行的所有记录,然后持续监听新记录:

code
const pump = async () => {
  const rows = await db
    .select()
    .from(runEvents)
    .where(and(eq(runEvents.projectId, project.id), gt(runEvents.id, cursor)))
    .orderBy(asc(runEvents.id));

  for (const r of rows) {
    cursor = r.id;
    send(`id: ${r.id}\ndata: ${JSON.stringify(r)}\n\n`);
  }
};

bus.on(project.id, pump); // 每个进程通过 LISTEN 连接触发
pump(); // 首次重放

由于所有事件都存储在数据库中,运行过程中刷新页面不会丢失任何信息。浏览器重新连接后会重放当前运行进度,并从断开处继续。停止按钮的工作原理类似,只是方向相反。网页应用发送包含运行 ID 的 NOTIFY,持有该运行的 worker 会中止它。

注意:LLM 流式传输的文本是按 token 逐个到达的,如果保存每个 token 会导致每轮数百条记录。因此 worker 会进行缓冲,每 250ms 写入一条记录。我还在此处发现了一个小错误,由于每个写入都是独立的 promise,导致事件顺序错乱。通过将所有写入操作串行化为单个 promise 链解决了这个问题。

实时预览网关

生成的应用的开发服务器在沙箱内部的 5173 端口运行,提供方会将其暴露在类似 https://5173-<sandbox-id>.sandbox.example 的 URL 下。此时,你可能会想到直接将这个 URL 放入 iframe 中并结束,但存在两个问题:

  • 为了加载该页面,浏览器需要我们的沙箱 API 密钥。显然,这显然不会发生。
  • 如果我们将其设为公开,任何猜到 URL 的人都可以打开它,而我们无法控制谁能看到什么内容。

因此,我们在其前面放置了自己的小型网关。它将 <project-id>.preview.localhost:4000 映射到正确的沙箱,并在服务器端添加 API 密钥:

code
const proxy = createProxyServer({ changeOrigin: true, secure: true, ws: true });

const server = http.createServer(async (req, res) => {
  const projectId = HOST_RE.exec(req.headers.host ?? "")?.[1];
  const target = await resolve(projectId); // 项目 -> 沙箱,缓存几秒钟
  if (!target.sandboxId) return send(res, noPreviewPage());

  delete req.headers.cookie; // 永远不要转发访客的凭证
  delete req.headers.authorization;
  proxy.web(req, res, {
    target: previewUrlFor(target.sandboxId),
    headers: { authorization: `Bearer ${API_KEY}` },
  });
});

// Vite 的热重载通过 WebSocket 运行,因此升级请求也会被代理。
server.on("upgrade", async (req, socket, head) => {
  const target = await resolve(HOST_RE.exec(req.headers.host ?? "")?.[1]);
  proxy.ws(req, socket, head, {
    target: previewUrlFor(target.sandboxId).replace(/^https/, "wss"),
    headers: { authorization: `Bearer ${API_KEY}` },
  });
});

这个升级处理程序使得预览体验更加生动。每当代理编辑文件时,Vite 会通过 WebSocket 推送更改,预览会实时更新而无需重新加载。

还有一个容易被忽视的网关存在的原因。预览运行在与主应用(localhost:3000)不同的源(*.preview.localhost)上。因此,生成的应用永远无法读取主应用的 Cookie 或以登录用户身份调用其 API。而且由于现代浏览器中 *.localhost 解析为 127.0.0.1,你甚至不需要修改 hosts 文件即可实现这一点。

注意:模板还会向预览中注入一个微型脚本,用于监听 window.onerror,并将其发送到父窗口。工作区随后将这些错误转发到后端,这就是代理的 get_browser_errors 工具获取真实运行时错误的地方。

使用 Git 的版本管理

每个完成的提示都会成为版本。因此,用户几乎可以像使用 git reset 一样回退到特定的“提示”。

为此,沙箱内部的普通 Git 工作得非常好。基础快照已经包含一个仓库,其中模板作为第一个提交,每次成功运行后,工作人员都会提交所有更改:

code
export async function commitAll(ps: ProjectSandbox, message: string) {
  const out = await git(
    ps,
    `git add -A
if git diff --cached --quiet; then
  echo NOCHANGE
else
  git commit -q -m "$MSG"
  git rev-parse HEAD
  git show --name-only --format= HEAD
fi`,
    { MSG: message },
  );
  if (out.trim() === "NOCHANGE") return null;
  const [sha, ...files] = out.trim().split("\n");
  return { sha, files };
}

你可能会好奇为什么里面没有 exit 0。这些命令是在 bash -l 环境下运行的,而在登录式 shell 中显式执行 exit 会触发 ~/.bash_logout 脚本。在这个镜像中,该脚本的最后一个命令执行失败,导致我的退出码变成了失败状态。这个问题真的花了我相当长的时间才解决。😭

恢复操作不会改写历史记录。它会让文件完全匹配旧提交的状态,然后将这个状态作为全新版本提交到历史中。因此 "恢复版本 1" 会创建版本 5,你仍然可以随时回到版本 4。没有任何内容会丢失。

#### 保持历史记录的持久性(沙箱中不使用令牌)

沙箱内的 Git 非常强大,但它的存在时间仅与沙箱本身一致。因此每次版本更新后,历史记录也会被推送到托管的 Git 仓库中。

最直接的做法是给沙箱一个 Git 访问令牌,然后直接运行 git push。但目前我能获取的令牌权限范围是整个项目,而不是单个仓库。将这种令牌放入装满不可信代码的沙箱中?嗯,还是算了吧。

因此沙箱本身不会执行任何推送操作。它会创建一个 git bundle(本质上是整个仓库打包成一个文件),工作者节点读取这个文件后,由工作者节点自己执行推送:

code
export async function pushToHostedGit(
  ps: ProjectSandbox,
  repo: string,
  dataDir: string,
) {
  await git(ps, "git bundle create -q /tmp/repo.bundle main");
  const bundle = await ps.sb.readFile("/tmp/repo.bundle");

  const mirror = join(dataDir, "git", `${repo}.git`); // 工作者节点上的裸仓库
  writeFileSync(join(mirror, "incoming.bundle"), bundle);
  await run("git", [
    "-C",
    mirror,
    "fetch",
    "-q",
    "--force",
    "incoming.bundle",
    "+refs/heads/main:refs/heads/main",
  ]);

  const cred = await repos.credential(repo); // 仅在工作者节点上短期有效的令牌
  await run("git", [
    "-C",
    mirror,
    "-c",
    `http.extraHeader=Authorization: Basic ${basic(cred)}`,
    "push",
    "-q",
    "--force",
    url,
    "main",
  ]);
}

这个设计的一个 nice side effect 是它也支持 remix 功能。当有人复制一个共享项目时,工作者节点会从托管仓库中加载源项目的 bundle 文件到全新沙箱中,而原始沙箱甚至不需要被唤醒就能完成这个操作。

沙箱的休眠、唤醒与共享机制

即使没有人正在使用,运行中的沙箱仍然会产生费用。因此有一个每分钟运行的小任务,会暂停任何过去 10 分钟没有代理运行的沙箱。暂停操作会保留内存状态,因此开发服务器会完全恢复到之前的状态。

唤醒操作发生在网关层。当有人打开一个休眠项目的预览时,网关会显示一个不断刷新的 "Waking up your app..." 页面,并在后台恢复沙箱:

code
if (isPage && target.status === "suspended") {
  const outcome = wake(projectId, target, log); // 每个项目去重处理
  const done = await Promise.race([outcome, timeout(4000, "pending")]);
  if (done === "busy") return send(res, busyPage());
  if (done !== "running") return send(res, wakingPage()); // 自动刷新
}

在我的测试中,一个休眠中的沙箱大约 3 秒后就能唤醒,应用也会立即加载完成。🎊

#### 限制数量的沙箱共享

大多数沙箱提供商对同时运行的沙箱数量有限制,尤其是在免费计划中。因此我添加了一个小型并发检查器,所有操作都在 Postgres 中通过建议锁实现,确保两个工作线程永远不会同时做出相同的决策:

code
async function decide(db: Db, projectId: string) {
  return db.transaction(async (tx) => {
    await tx.execute(sql`select pg_advisory_xact_lock(${LOCK_KEY})`);

    const live = await runningSandboxesOldestFirst(tx);
    if (live.some((s) => s.projectId === projectId)) return { kind: "ok" };
    if (live.length < concurrencyLimit()) return { kind: "ok" };

    // 已满。通过暂停最久未使用的空闲沙箱释放一个位置
    const victim = live.find((s) => !busyProjects.has(s.projectId));
    if (victim) return { kind: "evict", sandboxId: victim.sandboxId };

    return { kind: "wait", position }; // 所有沙箱都在运行,只能等待...
  });
}

为了测试这个功能,我将限制设为 1,当一个项目正在运行时,向另一个项目的空闲沙箱发送提示。空闲沙箱进入休眠状态,新项目立即获得其位置。然后在第二个项目仍在运行时向第一个项目发送提示,UI 显示 "等待可用沙箱,当前排第 #1" 直到位置释放。如果你使用的是高级计划,只需在 .env 中提高限制值,其他部分无需更改。

发布应用

预览功能在开发过程中非常有用,但你肯定不希望已发布应用依赖于每 10 分钟就会休眠的沙箱。🫩

因此发布功能会一次性构建应用并将其转换为静态文件:

code
export async function publish(project: Project) {
  const ps = await projectSandbox(project.id); // 如有必要会唤醒沙箱
  const build = await ps.exec(
    "rm -rf dist && npx vite build --outDir dist --emptyOutDir 2>&1",
    { timeoutSecs: 180 },
  );
  if (build.exitCode !== 0)
    throw new HttpError(422, `构建失败:\n${build.stdout.slice(-1500)}`);

  const files = await listFiles(ps, "dist");
  const prefix = `published/${slug}/${versionId}/`;

  for (const f of files) {
    const bytes = await ps.readBytes(`dist/${f}`);
    await storage.put(prefix + f, bytes, contentTypeFor(f), cacheControlFor(f));
  }

  await savePublishedSite({ projectId: project.id, slug, s3Prefix: prefix });
  return { url: publishedUrl(slug) };
}

网关随后会从 S3 的 http://<slug>.app.localhost:4000 提供这些文件。Vite 哈希处理的文件(如 assets/index-CScgwd68.js)将被永久缓存,index.html 始终会重新验证,因此更新会立即生效。所有没有文件扩展名的路径都会回退到 index.html,这样客户端路由也能正常工作。

已发布网站特意分配了独立的子域名。如果它们使用主应用的域名,已发布应用的 JavaScript 可能会使用查看者的 Cookie 调用你的 API,这显然是不可接受的。😺

在我的测试中,发布过程耗时约 6 秒,已发布网站在沙箱休眠状态下加载仅需 4 毫秒,因为它完全不接触沙箱。

应用构建器实测

以下是应用构建器的快速演示:

出于好奇,我还运行了一个小型评估测试。它将相同的三个提示(习惯追踪器、看板板和带图表的支出仪表板)发送给两个不同模型,每个都在独立的沙箱中运行:

模型 | 通过率(构建+渲染) | 平均耗时 --- | --- | --- Claude Sonnet 5 | 3/3 | 135秒 GPT 5.5 | 92秒

所有六个应用在首次检查时就成功构建并渲染,无需任何一次修复轮次,这确实让我感到有些意外。在Claude上,每个应用的成本大约在0.15到0.20美元之间。

结论

那么,你对这个项目有什么看法?这真的是我最近参与过的最有趣的项目之一,自从AI技术逐渐取代原始编码工作以来,这种体验已经很久没有过了。🤦‍♂️

当你使用Lovable等工具时,很容易认为一切的关键在于提示词和模型本身。但一旦你亲自构建一个,就会意识到大部分工作其实围绕模型展开,比如代码的运行环境、启动速度、如何向用户展示,以及如何防止模型执行不应做的事情。

如果非要让你从这次经历中带走一个观点,那就是沙盒机制的重要性。请将AI生成的代码视为不可信内容,为其分配一个没有秘密信息且网络访问权限极低的独立小型机器。通过内存快照和挂起/恢复功能,可以确保其运行速度和成本控制。

这个项目仍有大量扩展空间。你可以为模板添加一个小型后端(如Hono和SQLite),让用户能够构建完整的全栈应用;允许用户在预览界面中点击某个元素,直接编辑对应的组件;或者并排生成同一提示词的几种设计变体。不过我现在实在太疲惫,暂时无法实现这些功能。这些就交给你来完成了。✌️

基础架构已经就绪,其余工作只是在此基础上继续构建。

你可以在以下位置找到完整源代码:shricodev/lovable-build-tensorlake-aws

那么,这就是本文的全部内容。非常感谢你的阅读!我们下次再见。🫡

合作伙伴推荐课程

(在新标签页中打开)

ADVERTISEMENT

  • 在Udemy上,Diogo Alves de Resende的《使用Lovable、Cursor和Agent技能构建和发布应用》课程,评分4.6(4个评分),5小时,初级,证书,查看课程(在新标签页中打开)
  • 在Udemy上,Alex Dan的《AWS Kiro课程:面向2026年的基于规格的智能AI编码》,评分4.5(2,678个评分),4小时5分钟,初级,证书,查看课程(在新标签页中打开)
  • 在Udemy上,Maximilian Schwarzmüller的《AI代理与工作流:实用指南》,评分4.5(13,184个评分),4小时11分钟,初级,证书,查看课程(在新标签页中打开)
  • 在Coursera上,Anton Voroniuk的《初学者JavaScript入门》,8周课程,每周1小时,初级,证书,查看课程(在新标签页中打开)

更多Vibe Coding课程请访问Class Central

→

我从事软件和安全领域已有两年多时间,目前正在不断提升自己的技能。我了解云计算、DevOps和Web开发。除了这些,我还有网络安全方面的经验,曾协助过许多公司和大学,包括莫纳什大学、GymShark以及几家其他机构。

如果你读到了这里,请感谢作者以表达你的支持。说声谢谢

免费学习编程。freeCodeCamp的开源课程已帮助超过40,000人成功获得开发工作。立即开始学习